diff --git a/cmd/server/main.go b/cmd/server/main.go index 48cc223..ddb0b76 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/bigtux/ophion/internal/auth" + "github.com/bigtux/ophion/internal/otel" "github.com/bigtux/ophion/internal/security" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" @@ -271,6 +272,19 @@ func (s *Server) setupRoutes() { // Security headers 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 := s.app.Group("/api/v1") diff --git a/examples/otel-nodejs/README.md b/examples/otel-nodejs/README.md new file mode 100644 index 0000000..23e8309 --- /dev/null +++ b/examples/otel-nodejs/README.md @@ -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/ +``` + +## 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 diff --git a/examples/otel-nodejs/app.js b/examples/otel-nodejs/app.js new file mode 100644 index 0000000..2324abf --- /dev/null +++ b/examples/otel-nodejs/app.js @@ -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`); +}); diff --git a/examples/otel-nodejs/package.json b/examples/otel-nodejs/package.json new file mode 100644 index 0000000..445e697 --- /dev/null +++ b/examples/otel-nodejs/package.json @@ -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" + } +} diff --git a/examples/otel-nodejs/tracing.js b/examples/otel-nodejs/tracing.js new file mode 100644 index 0000000..5d75a04 --- /dev/null +++ b/examples/otel-nodejs/tracing.js @@ -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)); +}); diff --git a/examples/otel-python/README.md b/examples/otel-python/README.md new file mode 100644 index 0000000..6ba01ca --- /dev/null +++ b/examples/otel-python/README.md @@ -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/ +``` + +## 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 diff --git a/examples/otel-python/app.py b/examples/otel-python/app.py new file mode 100644 index 0000000..fb3a076 --- /dev/null +++ b/examples/otel-python/app.py @@ -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/') +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) diff --git a/examples/otel-python/requirements.txt b/examples/otel-python/requirements.txt new file mode 100644 index 0000000..2a131a9 --- /dev/null +++ b/examples/otel-python/requirements.txt @@ -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 diff --git a/examples/otel-python/tracing.py b/examples/otel-python/tracing.py new file mode 100644 index 0000000..cc591d2 --- /dev/null +++ b/examples/otel-python/tracing.py @@ -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() diff --git a/internal/otel/otlp_receiver.go b/internal/otel/otlp_receiver.go new file mode 100644 index 0000000..fa8b031 --- /dev/null +++ b/internal/otel/otlp_receiver.go @@ -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 +} diff --git a/server b/server index 10e750b..b6c274e 100755 Binary files a/server and b/server differ