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:
@@ -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")
|
||||
|
||||
|
||||
62
examples/otel-nodejs/README.md
Normal file
62
examples/otel-nodejs/README.md
Normal 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
160
examples/otel-nodejs/app.js
Normal 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`);
|
||||
});
|
||||
20
examples/otel-nodejs/package.json
Normal file
20
examples/otel-nodejs/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
examples/otel-nodejs/tracing.js
Normal file
52
examples/otel-nodejs/tracing.js
Normal 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));
|
||||
});
|
||||
85
examples/otel-python/README.md
Normal file
85
examples/otel-python/README.md
Normal 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
168
examples/otel-python/app.py
Normal 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)
|
||||
15
examples/otel-python/requirements.txt
Normal file
15
examples/otel-python/requirements.txt
Normal 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
|
||||
84
examples/otel-python/tracing.py
Normal file
84
examples/otel-python/tracing.py
Normal 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()
|
||||
393
internal/otel/otlp_receiver.go
Normal file
393
internal/otel/otlp_receiver.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user