feat: Add OpenTelemetry OTLP HTTP receiver

- Add POST /v1/traces endpoint for OTLP JSON trace ingestion
- Convert OTLP spans to internal format and save to PostgreSQL
- Manual JSON parsing (no Go 1.24 dependencies)
- Add Node.js instrumentation example with Express
- Add Python instrumentation example with Flask
- Auto-instrumentation support for both languages
This commit is contained in:
2026-02-06 14:59:29 -03:00
parent 8b6e59d346
commit 771cf6cf50
11 changed files with 1053 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/bigtux/ophion/internal/auth" "github.com/bigtux/ophion/internal/auth"
"github.com/bigtux/ophion/internal/otel"
"github.com/bigtux/ophion/internal/security" "github.com/bigtux/ophion/internal/security"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
@@ -271,6 +272,19 @@ func (s *Server) setupRoutes() {
// Security headers // Security headers
s.app.Use(security.SecurityHeaders()) s.app.Use(security.SecurityHeaders())
// ═══════════════════════════════════════════════════════════
// 🔭 OTLP HTTP Receiver (OpenTelemetry Protocol)
// ═══════════════════════════════════════════════════════════
// Standard OTLP endpoint - can be public or protected based on config
otlpReceiver := otel.NewOTLPReceiver(s.db)
// OTLP routes (public by default for easy integration)
// For production, consider adding auth middleware
s.app.Post("/v1/traces", otlpReceiver.HandleTraces)
// Also support the full path that some SDKs use
s.app.Post("/v1/traces/", otlpReceiver.HandleTraces)
// API v1 // API v1
api := s.app.Group("/api/v1") api := s.app.Group("/api/v1")

View File

@@ -0,0 +1,62 @@
# Node.js OpenTelemetry Example for Ophion
This example demonstrates how to instrument a Node.js application with OpenTelemetry and send traces to Ophion.
## Setup
```bash
# Install dependencies
npm install
# Start Ophion server (in another terminal)
# cd ~/projetos_jarvis/ophion && go run cmd/server/main.go
# Run the app with tracing
npm run trace
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:8080/v1/traces` | Ophion OTLP endpoint |
| `OTEL_SERVICE_NAME` | `nodejs-example` | Service name in traces |
| `PORT` | `3000` | App HTTP port |
## Test Endpoints
```bash
# Health check
curl http://localhost:3000/health
# Get all users (generates trace)
curl http://localhost:3000/users
# Get single user
curl http://localhost:3000/users/1
# Create order (complex trace with nested spans)
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"id": 1, "qty": 2}]}'
# Trigger error (error trace)
curl http://localhost:3000/error
```
## View Traces in Ophion
```bash
# List recent traces
curl http://localhost:8080/api/v1/traces
# Get specific trace
curl http://localhost:8080/api/v1/traces/<trace_id>
```
## How It Works
1. `tracing.js` - Initializes OpenTelemetry SDK with OTLP HTTP exporter
2. Auto-instrumentation captures HTTP requests automatically
3. Manual spans in `app.js` add custom business logic traces
4. All spans are sent to Ophion's `/v1/traces` endpoint in OTLP JSON format

160
examples/otel-nodejs/app.js Normal file
View File

@@ -0,0 +1,160 @@
// ═══════════════════════════════════════════════════════════
// 📱 Example Express App with OpenTelemetry
// ═══════════════════════════════════════════════════════════
//
// Run with: npm run trace
// Or: node --require ./tracing.js app.js
const express = require('express');
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const app = express();
const PORT = process.env.PORT || 3000;
// Get a tracer for manual instrumentation
const tracer = trace.getTracer('example-app');
// Simulated database
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
{ id: 3, name: 'Charlie', email: 'charlie@example.com' },
];
// Middleware to add request tracing
app.use((req, res, next) => {
const span = trace.getActiveSpan();
if (span) {
span.setAttribute('http.user_agent', req.get('user-agent') || 'unknown');
}
next();
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy', service: 'nodejs-example' });
});
// Get all users (with manual span)
app.get('/users', async (req, res) => {
// Create a custom span for database operation
const span = tracer.startSpan('db.query.users');
span.setAttribute('db.system', 'memory');
span.setAttribute('db.operation', 'SELECT');
try {
// Simulate database latency
await sleep(Math.random() * 100);
span.setAttribute('db.row_count', users.length);
span.setStatus({ code: SpanStatusCode.OK });
res.json({ users });
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
span.recordException(error);
res.status(500).json({ error: error.message });
} finally {
span.end();
}
});
// Get user by ID
app.get('/users/:id', async (req, res) => {
const userId = parseInt(req.params.id);
const span = tracer.startSpan('db.query.user_by_id');
span.setAttribute('db.system', 'memory');
span.setAttribute('db.operation', 'SELECT');
span.setAttribute('user.id', userId);
try {
await sleep(Math.random() * 50);
const user = users.find(u => u.id === userId);
if (!user) {
span.setStatus({ code: SpanStatusCode.ERROR, message: 'User not found' });
res.status(404).json({ error: 'User not found' });
} else {
span.setStatus({ code: SpanStatusCode.OK });
res.json({ user });
}
} finally {
span.end();
}
});
// Create order (simulates complex operation with nested spans)
app.post('/orders', express.json(), async (req, res) => {
const parentSpan = tracer.startSpan('order.create');
try {
// Step 1: Validate inventory
const inventorySpan = tracer.startSpan('inventory.check', {
attributes: { 'order.items': req.body.items?.length || 0 },
});
await sleep(Math.random() * 100);
inventorySpan.setStatus({ code: SpanStatusCode.OK });
inventorySpan.end();
// Step 2: Process payment
const paymentSpan = tracer.startSpan('payment.process', {
attributes: { 'payment.method': 'credit_card' },
});
await sleep(Math.random() * 200);
paymentSpan.setStatus({ code: SpanStatusCode.OK });
paymentSpan.end();
// Step 3: Create order record
const createSpan = tracer.startSpan('db.insert.order');
await sleep(Math.random() * 50);
const orderId = Date.now().toString(36);
createSpan.setAttribute('order.id', orderId);
createSpan.setStatus({ code: SpanStatusCode.OK });
createSpan.end();
parentSpan.setStatus({ code: SpanStatusCode.OK });
res.json({ orderId, status: 'created' });
} catch (error) {
parentSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
parentSpan.recordException(error);
res.status(500).json({ error: error.message });
} finally {
parentSpan.end();
}
});
// Simulate error endpoint (for testing error traces)
app.get('/error', (req, res) => {
const span = trace.getActiveSpan();
const error = new Error('Simulated error for testing');
if (span) {
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
}
res.status(500).json({ error: error.message });
});
// Helper
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Start server
app.listen(PORT, () => {
console.log(`🚀 Example app listening on http://localhost:${PORT}`);
console.log('');
console.log('Try these endpoints:');
console.log(` GET http://localhost:${PORT}/health`);
console.log(` GET http://localhost:${PORT}/users`);
console.log(` GET http://localhost:${PORT}/users/1`);
console.log(` POST http://localhost:${PORT}/orders`);
console.log(` GET http://localhost:${PORT}/error`);
});

View File

@@ -0,0 +1,20 @@
{
"name": "ophion-otel-nodejs-example",
"version": "1.0.0",
"description": "Example Node.js app instrumented with OpenTelemetry for Ophion",
"main": "app.js",
"scripts": {
"start": "node app.js",
"trace": "node --require ./tracing.js app.js"
},
"dependencies": {
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/auto-instrumentations-node": "^0.41.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.47.0",
"@opentelemetry/resources": "^1.21.0",
"@opentelemetry/sdk-node": "^0.47.0",
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"express": "^4.18.2"
}
}

View File

@@ -0,0 +1,52 @@
// ═══════════════════════════════════════════════════════════
// 🔭 OpenTelemetry Tracing Setup for Ophion
// ═══════════════════════════════════════════════════════════
//
// This file initializes OpenTelemetry tracing and sends spans
// to Ophion's OTLP HTTP endpoint.
//
// Usage: node --require ./tracing.js app.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
// Configure the OTLP exporter to send to Ophion
const traceExporter = new OTLPTraceExporter({
// Ophion OTLP endpoint - adjust host/port as needed
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:8080/v1/traces',
headers: {
// Optional: add authorization if Ophion requires it
// 'Authorization': `Bearer ${process.env.OPHION_API_KEY}`,
},
});
// Create the SDK with auto-instrumentation
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'nodejs-example',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || 'development',
}),
traceExporter,
instrumentations: [
getNodeAutoInstrumentations({
// Disable fs instrumentation to reduce noise
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
// Start the SDK
sdk.start();
console.log('🔭 OpenTelemetry tracing initialized - sending to Ophion');
// Graceful shutdown
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('OpenTelemetry SDK shut down'))
.catch((error) => console.error('Error shutting down SDK', error))
.finally(() => process.exit(0));
});

View File

@@ -0,0 +1,85 @@
# Python OpenTelemetry Example for Ophion
This example demonstrates how to instrument a Python Flask application with OpenTelemetry and send traces to Ophion.
## Setup
```bash
# Create virtual environment (recommended)
python -m venv venv
source venv/bin/activate # Linux/Mac
# or: venv\Scripts\activate # Windows
# Install dependencies
pip install -r requirements.txt
# Start Ophion server (in another terminal)
# cd ~/projetos_jarvis/ophion && go run cmd/server/main.go
# Run the app
python app.py
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:8080` | Ophion base URL |
| `OTEL_SERVICE_NAME` | `python-example` | Service name in traces |
| `PORT` | `5000` | App HTTP port |
## Test Endpoints
```bash
# Health check
curl http://localhost:5000/health
# Get all users (generates trace)
curl http://localhost:5000/users
# Get single user
curl http://localhost:5000/users/1
# Create order (complex trace with nested spans)
curl -X POST http://localhost:5000/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"id": 1, "qty": 2}]}'
# Trigger error (error trace)
curl http://localhost:5000/error
# External HTTP call (distributed tracing)
curl http://localhost:5000/external-call
```
## View Traces in Ophion
```bash
# List recent traces
curl http://localhost:8080/api/v1/traces
# Get specific trace
curl http://localhost:8080/api/v1/traces/<trace_id>
```
## Auto-Instrumentation Alternative
You can also use OpenTelemetry's auto-instrumentation:
```bash
# Install auto-instrumentation
pip install opentelemetry-distro
opentelemetry-bootstrap -a install
# Run with auto-instrumentation
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:8080 \
OTEL_SERVICE_NAME=python-example \
opentelemetry-instrument python app.py
```
## How It Works
1. `tracing.py` - Initializes OpenTelemetry SDK with OTLP HTTP exporter
2. `FlaskInstrumentor` auto-captures HTTP requests
3. Manual spans in `app.py` add custom business logic traces
4. All spans are sent to Ophion's `/v1/traces` endpoint in OTLP proto/JSON format

168
examples/otel-python/app.py Normal file
View File

@@ -0,0 +1,168 @@
"""
═══════════════════════════════════════════════════════════
📱 Example Flask App with OpenTelemetry
═══════════════════════════════════════════════════════════
Run with:
python app.py
Or with auto-instrumentation:
opentelemetry-instrument python app.py
"""
import os
import time
import random
import atexit
from flask import Flask, jsonify, request
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
from opentelemetry.instrumentation.flask import FlaskInstrumentor
# Initialize tracing BEFORE creating Flask app
from tracing import init_tracing, get_tracer, shutdown
init_tracing()
tracer = get_tracer()
app = Flask(__name__)
# Auto-instrument Flask
FlaskInstrumentor().instrument_app(app)
# Register shutdown handler
atexit.register(shutdown)
# Simulated database
users = [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
{"id": 3, "name": "Charlie", "email": "charlie@example.com"},
]
@app.route('/health')
def health():
"""Health check endpoint."""
return jsonify({"status": "healthy", "service": "python-example"})
@app.route('/users')
def get_users():
"""Get all users with custom span."""
with tracer.start_as_current_span("db.query.users") as span:
span.set_attribute("db.system", "memory")
span.set_attribute("db.operation", "SELECT")
# Simulate database latency
time.sleep(random.uniform(0.01, 0.1))
span.set_attribute("db.row_count", len(users))
span.set_status(Status(StatusCode.OK))
return jsonify({"users": users})
@app.route('/users/<int:user_id>')
def get_user(user_id: int):
"""Get user by ID."""
with tracer.start_as_current_span("db.query.user_by_id") as span:
span.set_attribute("db.system", "memory")
span.set_attribute("db.operation", "SELECT")
span.set_attribute("user.id", user_id)
# Simulate database latency
time.sleep(random.uniform(0.01, 0.05))
user = next((u for u in users if u["id"] == user_id), None)
if not user:
span.set_status(Status(StatusCode.ERROR, "User not found"))
return jsonify({"error": "User not found"}), 404
span.set_status(Status(StatusCode.OK))
return jsonify({"user": user})
@app.route('/orders', methods=['POST'])
def create_order():
"""Create order with nested spans."""
with tracer.start_as_current_span("order.create") as parent_span:
try:
data = request.get_json() or {}
items = data.get("items", [])
# Step 1: Validate inventory
with tracer.start_as_current_span("inventory.check") as span:
span.set_attribute("order.items", len(items))
time.sleep(random.uniform(0.05, 0.1))
span.set_status(Status(StatusCode.OK))
# Step 2: Process payment
with tracer.start_as_current_span("payment.process") as span:
span.set_attribute("payment.method", "credit_card")
time.sleep(random.uniform(0.1, 0.2))
span.set_status(Status(StatusCode.OK))
# Step 3: Create order record
with tracer.start_as_current_span("db.insert.order") as span:
time.sleep(random.uniform(0.02, 0.05))
order_id = hex(int(time.time() * 1000))[2:]
span.set_attribute("order.id", order_id)
span.set_status(Status(StatusCode.OK))
parent_span.set_status(Status(StatusCode.OK))
return jsonify({"orderId": order_id, "status": "created"})
except Exception as e:
parent_span.set_status(Status(StatusCode.ERROR, str(e)))
parent_span.record_exception(e)
return jsonify({"error": str(e)}), 500
@app.route('/error')
def trigger_error():
"""Trigger error for testing."""
span = trace.get_current_span()
error = Exception("Simulated error for testing")
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR, str(error)))
return jsonify({"error": str(error)}), 500
@app.route('/external-call')
def external_call():
"""Make external HTTP call (demonstrates distributed tracing)."""
import requests
with tracer.start_as_current_span("external.http.call") as span:
span.set_attribute("http.url", "https://httpbin.org/get")
try:
# Note: requests auto-instrumentation propagates trace context
response = requests.get("https://httpbin.org/get", timeout=5)
span.set_attribute("http.status_code", response.status_code)
span.set_status(Status(StatusCode.OK))
return jsonify({"status": "ok", "external_status": response.status_code})
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
port = int(os.getenv('PORT', 5000))
print(f"""
🚀 Example app listening on http://localhost:{port}
Try these endpoints:
GET http://localhost:{port}/health
GET http://localhost:{port}/users
GET http://localhost:{port}/users/1
POST http://localhost:{port}/orders
GET http://localhost:{port}/error
GET http://localhost:{port}/external-call
""")
app.run(host='0.0.0.0', port=port, debug=False)

View File

@@ -0,0 +1,15 @@
# OpenTelemetry SDK and API
opentelemetry-api>=1.22.0
opentelemetry-sdk>=1.22.0
# OTLP HTTP Exporter
opentelemetry-exporter-otlp-proto-http>=1.22.0
# Auto-instrumentation
opentelemetry-instrumentation>=0.43b0
opentelemetry-instrumentation-flask>=0.43b0
opentelemetry-instrumentation-requests>=0.43b0
# Web framework
flask>=3.0.0
requests>=2.31.0

View File

@@ -0,0 +1,84 @@
"""
═══════════════════════════════════════════════════════════
🔭 OpenTelemetry Tracing Setup for Ophion
═══════════════════════════════════════════════════════════
This module initializes OpenTelemetry tracing and sends spans
to Ophion's OTLP HTTP endpoint.
Usage:
from tracing import init_tracing, get_tracer
init_tracing()
tracer = get_tracer()
"""
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION, DEPLOYMENT_ENVIRONMENT
# Global tracer
_tracer = None
def init_tracing(service_name: str = None) -> None:
"""
Initialize OpenTelemetry tracing with OTLP HTTP exporter.
Args:
service_name: Name of the service (default: from env or 'python-example')
"""
global _tracer
# Get configuration from environment
otlp_endpoint = os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:8080')
service = service_name or os.getenv('OTEL_SERVICE_NAME', 'python-example')
environment = os.getenv('DEPLOYMENT_ENVIRONMENT', 'development')
# Create resource with service info
resource = Resource.create({
SERVICE_NAME: service,
SERVICE_VERSION: '1.0.0',
DEPLOYMENT_ENVIRONMENT: environment,
})
# Create tracer provider
provider = TracerProvider(resource=resource)
# Configure OTLP HTTP exporter
# Note: endpoint should be base URL, SDK adds /v1/traces
exporter = OTLPSpanExporter(
endpoint=f"{otlp_endpoint}/v1/traces",
# headers={"Authorization": f"Bearer {os.getenv('OPHION_API_KEY', '')}"},
)
# Add batch processor for efficient sending
processor = BatchSpanProcessor(exporter)
provider.add_span_processor(processor)
# Set as global tracer provider
trace.set_tracer_provider(provider)
# Create tracer
_tracer = trace.get_tracer(__name__)
print(f"🔭 OpenTelemetry tracing initialized")
print(f" Service: {service}")
print(f" Endpoint: {otlp_endpoint}/v1/traces")
def get_tracer():
"""Get the initialized tracer."""
global _tracer
if _tracer is None:
init_tracing()
return _tracer
def shutdown():
"""Gracefully shutdown the tracer provider."""
provider = trace.get_tracer_provider()
if hasattr(provider, 'shutdown'):
provider.shutdown()

View File

@@ -0,0 +1,393 @@
package otel
import (
"database/sql"
"encoding/hex"
"encoding/json"
"log"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
// ═══════════════════════════════════════════════════════════
// 🔭 OTLP HTTP Receiver - OpenTelemetry Protocol Support
// ═══════════════════════════════════════════════════════════
//
// Implements OTLP HTTP JSON receiver for traces
// Based on: https://opentelemetry.io/docs/specs/otlp/#otlphttp
//
// No external dependencies - manual JSON parsing
// OTLPReceiver handles OTLP HTTP requests
type OTLPReceiver struct {
db *sql.DB
}
// NewOTLPReceiver creates a new OTLP receiver
func NewOTLPReceiver(db *sql.DB) *OTLPReceiver {
return &OTLPReceiver{db: db}
}
// ─────────────────────────────────────────────────────────────
// OTLP JSON Structures (manual parsing, no protobuf)
// ─────────────────────────────────────────────────────────────
// ExportTraceServiceRequest is the top-level OTLP trace request
type ExportTraceServiceRequest struct {
ResourceSpans []ResourceSpans `json:"resourceSpans"`
}
// ResourceSpans groups spans by resource
type ResourceSpans struct {
Resource Resource `json:"resource"`
ScopeSpans []ScopeSpans `json:"scopeSpans"`
}
// Resource describes the source of telemetry
type Resource struct {
Attributes []KeyValue `json:"attributes"`
}
// ScopeSpans groups spans by instrumentation scope
type ScopeSpans struct {
Scope InstrumentationScope `json:"scope"`
Spans []OTLPSpan `json:"spans"`
}
// InstrumentationScope identifies the instrumentation library
type InstrumentationScope struct {
Name string `json:"name"`
Version string `json:"version"`
}
// OTLPSpan represents a span in OTLP format
type OTLPSpan struct {
TraceID string `json:"traceId"`
SpanID string `json:"spanId"`
ParentSpanID string `json:"parentSpanId,omitempty"`
Name string `json:"name"`
Kind int `json:"kind"`
StartTimeUnixNano string `json:"startTimeUnixNano"`
EndTimeUnixNano string `json:"endTimeUnixNano"`
Attributes []KeyValue `json:"attributes,omitempty"`
Status SpanStatus `json:"status,omitempty"`
Events []Event `json:"events,omitempty"`
}
// SpanStatus represents the status of a span
type SpanStatus struct {
Code int `json:"code"`
Message string `json:"message,omitempty"`
}
// KeyValue is an OTLP key-value pair
type KeyValue struct {
Key string `json:"key"`
Value AnyValue `json:"value"`
}
// AnyValue can hold different types of values
type AnyValue struct {
StringValue string `json:"stringValue,omitempty"`
IntValue string `json:"intValue,omitempty"`
DoubleValue float64 `json:"doubleValue,omitempty"`
BoolValue bool `json:"boolValue,omitempty"`
ArrayValue *ArrayValue `json:"arrayValue,omitempty"`
}
// ArrayValue holds an array of values
type ArrayValue struct {
Values []AnyValue `json:"values"`
}
// Event represents a span event
type Event struct {
Name string `json:"name"`
TimeUnixNano string `json:"timeUnixNano"`
Attributes []KeyValue `json:"attributes,omitempty"`
}
// ─────────────────────────────────────────────────────────────
// Internal Span Format (matches existing Ophion format)
// ─────────────────────────────────────────────────────────────
type InternalSpan struct {
TraceID string `json:"trace_id"`
SpanID string `json:"span_id"`
ParentSpanID string `json:"parent_span_id,omitempty"`
Service string `json:"service"`
Operation string `json:"operation"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DurationNs int64 `json:"duration_ns"`
StatusCode string `json:"status_code"`
StatusMsg string `json:"status_message,omitempty"`
Kind string `json:"kind"`
Attributes map[string]any `json:"attributes,omitempty"`
}
// ─────────────────────────────────────────────────────────────
// HTTP Handler
// ─────────────────────────────────────────────────────────────
// HandleTraces handles POST /v1/traces for OTLP trace ingestion
func (r *OTLPReceiver) HandleTraces(c *fiber.Ctx) error {
contentType := c.Get("Content-Type")
// OTLP supports both JSON and protobuf, we only support JSON
if !strings.Contains(contentType, "application/json") && contentType != "" {
return c.Status(415).JSON(fiber.Map{
"error": "Unsupported media type. Use application/json",
})
}
var req ExportTraceServiceRequest
if err := c.BodyParser(&req); err != nil {
log.Printf("OTLP parse error: %v", err)
return c.Status(400).JSON(fiber.Map{
"error": "Failed to parse OTLP request: " + err.Error(),
})
}
spans := r.convertOTLPToInternal(req)
if len(spans) == 0 {
return c.JSON(fiber.Map{
"partialSuccess": fiber.Map{
"rejectedSpans": 0,
},
})
}
// Save to database
savedCount := 0
for _, sp := range spans {
attrs, _ := json.Marshal(sp.Attributes)
_, err := r.db.Exec(`
INSERT INTO spans (trace_id, span_id, parent_span_id, service, operation, start_time, end_time, duration_ns, status_code, status_message, kind, attributes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
sp.TraceID, sp.SpanID, sp.ParentSpanID, sp.Service, sp.Operation, sp.StartTime, sp.EndTime, sp.DurationNs, sp.StatusCode, sp.StatusMsg, sp.Kind, attrs)
if err != nil {
log.Printf("Error inserting OTLP span: %v", err)
} else {
savedCount++
}
}
log.Printf("📡 OTLP: Received %d spans, saved %d", len(spans), savedCount)
// OTLP response format
return c.JSON(fiber.Map{
"partialSuccess": fiber.Map{
"rejectedSpans": len(spans) - savedCount,
},
})
}
// ─────────────────────────────────────────────────────────────
// Conversion Functions
// ─────────────────────────────────────────────────────────────
func (r *OTLPReceiver) convertOTLPToInternal(req ExportTraceServiceRequest) []InternalSpan {
var result []InternalSpan
for _, rs := range req.ResourceSpans {
serviceName := extractServiceName(rs.Resource)
for _, ss := range rs.ScopeSpans {
for _, span := range ss.Spans {
internal := InternalSpan{
TraceID: normalizeTraceID(span.TraceID),
SpanID: normalizeSpanID(span.SpanID),
ParentSpanID: normalizeSpanID(span.ParentSpanID),
Service: serviceName,
Operation: span.Name,
StartTime: parseNanoTimestamp(span.StartTimeUnixNano),
EndTime: parseNanoTimestamp(span.EndTimeUnixNano),
StatusCode: convertStatusCode(span.Status.Code),
StatusMsg: span.Status.Message,
Kind: convertSpanKind(span.Kind),
Attributes: convertAttributes(span.Attributes),
}
// Calculate duration
internal.DurationNs = internal.EndTime.Sub(internal.StartTime).Nanoseconds()
// Add scope info to attributes
if ss.Scope.Name != "" {
if internal.Attributes == nil {
internal.Attributes = make(map[string]any)
}
internal.Attributes["otel.library.name"] = ss.Scope.Name
if ss.Scope.Version != "" {
internal.Attributes["otel.library.version"] = ss.Scope.Version
}
}
// Add events as attribute if present
if len(span.Events) > 0 {
if internal.Attributes == nil {
internal.Attributes = make(map[string]any)
}
internal.Attributes["span.events"] = convertEvents(span.Events)
}
result = append(result, internal)
}
}
}
return result
}
// extractServiceName gets service.name from resource attributes
func extractServiceName(resource Resource) string {
for _, attr := range resource.Attributes {
if attr.Key == "service.name" {
return getStringValue(attr.Value)
}
}
return "unknown"
}
// normalizeTraceID converts trace ID to consistent format (32 hex chars)
func normalizeTraceID(id string) string {
if id == "" {
return ""
}
// If it's base64, decode it
if len(id) > 32 {
if decoded, err := hex.DecodeString(id); err == nil {
return hex.EncodeToString(decoded)
}
}
// Remove any non-hex characters and ensure lowercase
return strings.ToLower(strings.TrimSpace(id))
}
// normalizeSpanID converts span ID to consistent format (16 hex chars)
func normalizeSpanID(id string) string {
if id == "" {
return ""
}
return strings.ToLower(strings.TrimSpace(id))
}
// parseNanoTimestamp converts nanosecond timestamp string to time.Time
func parseNanoTimestamp(ns string) time.Time {
if ns == "" {
return time.Time{}
}
// Parse as int64 - try direct string parsing first
var nanos int64
for _, c := range ns {
if c >= '0' && c <= '9' {
nanos = nanos*10 + int64(c-'0')
}
}
if nanos == 0 {
// Fallback: try JSON unmarshal
json.Unmarshal([]byte(ns), &nanos)
}
return time.Unix(0, nanos)
}
// convertStatusCode converts OTLP status code to string
func convertStatusCode(code int) string {
switch code {
case 0:
return "UNSET"
case 1:
return "OK"
case 2:
return "ERROR"
default:
return "UNSET"
}
}
// convertSpanKind converts OTLP span kind to string
func convertSpanKind(kind int) string {
switch kind {
case 0:
return "UNSPECIFIED"
case 1:
return "INTERNAL"
case 2:
return "SERVER"
case 3:
return "CLIENT"
case 4:
return "PRODUCER"
case 5:
return "CONSUMER"
default:
return "INTERNAL"
}
}
// convertAttributes converts OTLP KeyValue array to map
func convertAttributes(attrs []KeyValue) map[string]any {
if len(attrs) == 0 {
return nil
}
result := make(map[string]any, len(attrs))
for _, attr := range attrs {
result[attr.Key] = getAnyValue(attr.Value)
}
return result
}
// getStringValue extracts string value from AnyValue
func getStringValue(v AnyValue) string {
if v.StringValue != "" {
return v.StringValue
}
if v.IntValue != "" {
return v.IntValue
}
return ""
}
// getAnyValue extracts the actual value from AnyValue
func getAnyValue(v AnyValue) any {
if v.StringValue != "" {
return v.StringValue
}
if v.IntValue != "" {
return v.IntValue
}
if v.DoubleValue != 0 {
return v.DoubleValue
}
if v.BoolValue {
return v.BoolValue
}
if v.ArrayValue != nil {
var arr []any
for _, av := range v.ArrayValue.Values {
arr = append(arr, getAnyValue(av))
}
return arr
}
return nil
}
// convertEvents converts OTLP events to a serializable format
func convertEvents(events []Event) []map[string]any {
var result []map[string]any
for _, e := range events {
ev := map[string]any{
"name": e.Name,
"time": parseNanoTimestamp(e.TimeUnixNano),
}
if len(e.Attributes) > 0 {
ev["attributes"] = convertAttributes(e.Attributes)
}
result = append(result, ev)
}
return result
}

BIN
server

Binary file not shown.