From 1027e836a1313510e3a49498bf8ac1a42252fac8 Mon Sep 17 00:00:00 2001 From: Ashutosh Anshu Date: Sun, 6 Jul 2025 02:11:13 +0530 Subject: [PATCH 1/2] Add OpenTelemetry instrumentation for user API - Introduced new package `pulse_otel` for OpenTelemetry integration. - Implemented HTTP middleware for tracing requests and responses. - Created main application with user management endpoints (`getUsers`, `createUser`, `health`, `myPost`). - Added utility functions for database operations with tracing. - Configured OpenTelemetry with project-specific tracing. - Enhanced error handling and response formatting. - Updated `go.mod` and `go.sum` for new dependencies. - Added requirements for Python dependencies in `requirements.txt`. --- examples/otel-collector/Dockerfile.go-webapp | 16 + .../{Dockerfile => Dockerfile.myapp} | 4 +- examples/otel-collector/Dockerfile.myapp_2 | 15 + examples/otel-collector/docker-compose.yml | 21 +- examples/otel-collector/myapp.py | 337 ++++++++++++++++++ .../otel-collector/{main.py => myapp_2.py} | 116 +++++- .../pulse_go_otel_instrumentor/config.go | 246 +++++++++++++ .../pulse_go_otel_instrumentor/const.go | 3 + .../pulse_go_otel_instrumentor/go.mod | 30 ++ .../pulse_go_otel_instrumentor/go.sum | 49 +++ .../pulse_go_otel_instrumentor/main/main.go | 312 ++++++++++++++++ .../pulse_go_otel_instrumentor/middleware.go | 291 +++++++++++++++ .../pulse_go_otel_instrumentor/tracer.go | 183 ++++++++++ .../pulse_go_otel_instrumentor/utils.go | 25 ++ examples/otel-collector/requirements.txt | 5 + 15 files changed, 1647 insertions(+), 6 deletions(-) create mode 100644 examples/otel-collector/Dockerfile.go-webapp rename examples/otel-collector/{Dockerfile => Dockerfile.myapp} (84%) create mode 100644 examples/otel-collector/Dockerfile.myapp_2 create mode 100644 examples/otel-collector/myapp.py rename examples/otel-collector/{main.py => myapp_2.py} (62%) create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/config.go create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/const.go create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/go.mod create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/go.sum create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/main/main.go create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/middleware.go create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/tracer.go create mode 100644 examples/otel-collector/pulse_go_otel_instrumentor/utils.go diff --git a/examples/otel-collector/Dockerfile.go-webapp b/examples/otel-collector/Dockerfile.go-webapp new file mode 100644 index 0000000..a726636 --- /dev/null +++ b/examples/otel-collector/Dockerfile.go-webapp @@ -0,0 +1,16 @@ +FROM golang:1.22-alpine + +WORKDIR /app + +# Copy the entire pulse_go_otel_instrumentor directory to maintain package structure +COPY ./pulse_go_otel_instrumentor/ ./ + +# Download dependencies +RUN go mod download + +# Build the main application from the main subdirectory +RUN go build -o main ./main + +EXPOSE 8093 + +CMD ["./main/main"] diff --git a/examples/otel-collector/Dockerfile b/examples/otel-collector/Dockerfile.myapp similarity index 84% rename from examples/otel-collector/Dockerfile rename to examples/otel-collector/Dockerfile.myapp index 4eba6f5..6b39842 100644 --- a/examples/otel-collector/Dockerfile +++ b/examples/otel-collector/Dockerfile.myapp @@ -7,9 +7,9 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt RUN pip3 install git+https://github.com/singlestore-labs/singlestore-pulse.git@master -COPY main.py . +COPY myapp.py . COPY .env . EXPOSE 8000 -CMD ["python", "main.py"] +CMD ["python", "myapp.py"] diff --git a/examples/otel-collector/Dockerfile.myapp_2 b/examples/otel-collector/Dockerfile.myapp_2 new file mode 100644 index 0000000..4846ca8 --- /dev/null +++ b/examples/otel-collector/Dockerfile.myapp_2 @@ -0,0 +1,15 @@ +# Dockerfile +FROM python:3.11 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +RUN pip3 install git+https://github.com/singlestore-labs/singlestore-pulse.git@master + +COPY myapp_2.py . +COPY .env . + +EXPOSE 8000 + +CMD ["python", "myapp_2.py"] diff --git a/examples/otel-collector/docker-compose.yml b/examples/otel-collector/docker-compose.yml index e22f8c8..7eae1fb 100644 --- a/examples/otel-collector/docker-compose.yml +++ b/examples/otel-collector/docker-compose.yml @@ -4,13 +4,32 @@ services: myapp: build: context: . - dockerfile: Dockerfile + dockerfile: Dockerfile.myapp ports: - "8007:8000" depends_on: - otel-collector networks: - otel-network + myapp_2: + build: + context: . + dockerfile: Dockerfile.myapp_2 + ports: + - "8008:8000" + depends_on: + - otel-collector + networks: + - otel-network + + go-webapp: + build: + context: . + dockerfile: Dockerfile.go-webapp + ports: + - "8009:8093" + networks: + - otel-network otel-collector: image: otel/opentelemetry-collector:latest diff --git a/examples/otel-collector/myapp.py b/examples/otel-collector/myapp.py new file mode 100644 index 0000000..f656d5c --- /dev/null +++ b/examples/otel-collector/myapp.py @@ -0,0 +1,337 @@ +from dotenv import load_dotenv +import os +import datetime +import json +from fastapi import FastAPI, HTTPException +from fastapi import Request +import uvicorn +from pydantic import BaseModel + +from openai import OpenAI +import requests +from fastapi.responses import JSONResponse +from opentelemetry.sdk._logs import LoggingHandler + +from pulse_otel import Pulse, pulse_agent, pulse_tool + +import logging +from tenacity import retry, stop_after_attempt, wait_fixed + +app = FastAPI(title="My time agent", description="A FastAPI app that uses Pulse OTel for tracing and logging", version="1.0.0") + +# Define a Pydantic model for the request body +class AgentRunRequest(BaseModel): + prompt: str + session_id: str = None # Optional session ID + +class Item(BaseModel): + id: int + name: str + price: float + +logger = logging.getLogger("myapp") +logger.setLevel(logging.DEBUG) + +def get_configs(): + """ + Reads and returns configurations from the .env file. + """ + load_dotenv() # Load environment variables from .env file + configs = { + "perma_auth_token": os.getenv("perma_auth_token"), + "api_uri": os.getenv("api_uri"), + "model_name": os.getenv("model_name"), + } + return configs + +# Define available tools +tools = [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get the current time in HH:MM:SS format", + "parameters": {} + } + }, + { + "type": "function", + "function": { + "name": "get_current_date", + "description": "Get the current date in YYYY-MM-DD format", + "parameters": {} + } + }, + { + "type": "function", + "function": { + "name": "get_funny_current_time", + "description": "Get the current time in HH:MM:SS format with a funny phrase", + "parameters": { + "type": "object", + "properties": { + "funny_phrase": { + "type": "string", + "description": "A humorous phrase to include with the time" + } + }, + "required": ["funny_phrase"] + } + } + } +] + +# Define a simple tool: a function to get the current time +@pulse_tool() +def get_current_time(): + logger.info("TEST LOGS get_current_time") + logger.debug("DEBUG LOGS get_current_time") + logger.critical("CRITICAL LOGS get_current_time") + return datetime.datetime.now().strftime("%H:%M:%S") + +# Define a new tool: a function to get the current date +@pulse_tool(name="ToolA") +def get_current_date(): + logger.critical("CRITICAL LOGS of get_current_date") + logger.debug("DEBUG LOGS of get_current_date") + logger.info("INFO LOGS of get_current_date") + return datetime.datetime.now().strftime("%Y-%m-%d") + +# Define a new tool: a function to get the current time with a funny phrase +@retry(stop=stop_after_attempt(3), wait=wait_fixed(2)) +@pulse_tool("toolB") +def get_funny_current_time(funny_phrase): + logger.critical("CRITICAL LOGS of get_funny_current_time") + logger.debug("DEBUG LOGS of get_funny_current_time") + logger.info("INFO LOGS of get_funny_current_time") + + current_time = datetime.datetime.now().strftime("%H:%M:%S") + funny_timestamp = f"{funny_phrase}! The time is {current_time}" + return get_funny_timestamp_phrase(funny_timestamp) + +def get_funny_timestamp_phrase(funny_timestamp): + logger.info("TEST LOGS get_funny_timestamp_phrase") + logger.debug("DEBUG LOGS get_funny_timestamp_phrase") + logger.critical("CRITICAL LOGS get_funny_timestamp_phrase") + return f"Here is a funny timestamp: {funny_timestamp}" + +# Simple agent function to process user input and decide on tool use +@app.post("/agent/run") +@pulse_agent("MyDockerTimeAgent") +def agent_run(request: Request, body: AgentRunRequest): # Changed back to sync function + try: + prompt = body.prompt + messages = [{"role": "user", "content": prompt}] + + configs = get_configs() + + # Validate required configs + if not configs["perma_auth_token"] or not configs["api_uri"] or not configs["model_name"]: + raise HTTPException(status_code=500, detail="Missing required configuration") + + client = OpenAI( + api_key=configs["perma_auth_token"], + base_url=configs["api_uri"], + ) + + # Make a chat completion request with tools + response = client.chat.completions.create( + model=configs["model_name"], + messages=messages, + tools=tools, + tool_choice="auto", + extra_headers={"X-Session-ID": "session_id"}, + ) + + # Check if the response involves a tool call + if response.choices[0].message.tool_calls: + results = [] + for tool_call in response.choices[0].message.tool_calls: + if tool_call.function.name == "get_current_time": + result = get_current_time() + results.append(result) + elif tool_call.function.name == "get_current_date": + result = get_current_date() + results.append(result) + elif tool_call.function.name == "get_funny_current_time": + arguments = json.loads(tool_call.function.arguments) + funny_phrase = arguments.get("funny_phrase", "Just kidding") + result = get_funny_current_time(funny_phrase) + results.append(result) + + # Return the first result (or combine multiple results if needed) + return {"response": results[0] if len(results) == 1 else results} + else: + return {"response": response.choices[0].message.content} + + except Exception as e: + logger.error(f"Error in agent_run: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +def http_req(body: Item): + """ + Makes an HTTP request to the myapp_2 service at the /cftocf endpoint. + """ + url = "http://myapp_2:8000/getdata" + + data = { + "name": body.name, + "price": body.price, + "id": body.id + } + + # Set headers + headers = { + "Content-Type": "application/json" + } + + try: + # Make the POST request with timeout + response = requests.post(url, json=data, headers=headers, timeout=30) + + # Check if the request was successful + response.raise_for_status() + + # Log successful response + logger.info(f"HTTP request to myapp_2 successful. Status Code: {response.status_code}") + + return { + "status": "success", + "status_code": response.status_code, + "response": response.json() if response.headers.get('content-type', '').startswith('application/json') else response.text + } + + except requests.exceptions.Timeout: + logger.error("HTTP request to myapp_2 timed out") + raise HTTPException(status_code=504, detail="Request to myapp_2 service timed out") + + except requests.exceptions.ConnectionError: + logger.error("Failed to connect to myapp_2 service") + raise HTTPException(status_code=502, detail="Failed to connect to myapp_2 service") + + except requests.exceptions.HTTPError as e: + logger.error(f"HTTP error occurred when calling myapp_2: {e}") + raise HTTPException(status_code=response.status_code, detail=f"myapp_2 service error: {response.text}") + + except requests.exceptions.RequestException as e: + logger.error(f"Request error occurred when calling myapp_2: {e}") + raise HTTPException(status_code=500, detail="Failed to make request to myapp_2 service") + +# Define a health check endpoint +@app.get("/health") +def health_check(): + return {"status": "ok"} + +# Define the root endpoint for FastAPI +@app.get("/") +def root(): + return {"message": "Welcome to the Pulse OTel FastAPI agent!"} + +# @pulse_agent("MyCloudFunctionToCloudFunctionAgent") +@app.post("/cloud_function_to_cloud_function") +@pulse_agent("MyCloudFunctionToCloudFunctionAgent") +def agent_run_2(request: Request, body: Item): + try: + return http_req(body) + except Exception as e: + logger.error(f"Error in cloud_function_to_cloud_function: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +def http_req_go_service(body: Item): + """ + Makes an HTTP request to the Go webapp service at the /api/process endpoint. + """ + url = "http://go-webapp:8000/api/process" + + data = { + "id": body.id, + "name": body.name, + "price": body.price + } + + headers = { + "Content-Type": "application/json" + } + + try: + # Make the POST request with timeout + response = requests.post(url, json=data, headers=headers, timeout=30) + + # Check if the request was successful + response.raise_for_status() + + # Log successful response + logger.info(f"HTTP request to go-webapp successful. Status Code: {response.status_code}") + + return { + "status": "success", + "status_code": response.status_code, + "response": response.json() if response.headers.get('content-type', '').startswith('application/json') else response.text + } + + except requests.exceptions.Timeout: + logger.error("HTTP request to go-webapp timed out") + raise HTTPException(status_code=504, detail="Request to go-webapp service timed out") + + except requests.exceptions.ConnectionError: + logger.error("Failed to connect to go-webapp service") + raise HTTPException(status_code=502, detail="Failed to connect to go-webapp service") + + except requests.exceptions.HTTPError as e: + logger.error(f"HTTP error occurred when calling go-webapp: {e}") + raise HTTPException(status_code=response.status_code, detail=f"go-webapp service error: {response.text}") + + except requests.exceptions.RequestException as e: + logger.error(f"Request error occurred when calling go-webapp: {e}") + raise HTTPException(status_code=500, detail="Failed to make request to go-webapp service") + +@pulse_agent("MyGoServiceAgent") +@app.post("/call-go-service") +def call_go_service(request: Request, body: Item): + """ + Endpoint that calls the Go webapp service + """ + try: + return http_req_go_service(body) + except Exception as e: + logger.error(f"Error in call-go-service: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +@app.post("/go_py_py") +def cftocf_endpoint(request: Request, body: Item): + """ + This is the target endpoint that myapp will call. + It processes the item and returns a response. + """ + try: + # Process the item (you can add your business logic here) + processed_data = { + "id": body.id, + "name": body.name, + "price": body.price + } + + logger.info(f"Successfully processed item {body.id}") + + # Call the http_req function to make an HTTP request to another service + return { + "status": "success", + "data": processed_data, + "message": "Item processed successfully" + } + + except Exception as e: + logger.error(f"Error in cftocf endpoint: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +def main(): + # write to otel collector + _ = Pulse( + otel_collector_endpoint="http://otel-collector:4317", + ) + + # Create a FastAPI app + uvicorn.run(app, host="0.0.0.0", port=8000) + +if __name__ == "__main__": + main() diff --git a/examples/otel-collector/main.py b/examples/otel-collector/myapp_2.py similarity index 62% rename from examples/otel-collector/main.py rename to examples/otel-collector/myapp_2.py index 40e494e..b77b840 100644 --- a/examples/otel-collector/main.py +++ b/examples/otel-collector/myapp_2.py @@ -2,6 +2,8 @@ import os import datetime import json +import requests + from fastapi import FastAPI, HTTPException from fastapi import Request import uvicorn @@ -23,6 +25,11 @@ class AgentRunRequest(BaseModel): prompt: str session_id: str = None # Optional session ID +class Item(BaseModel): + id: int + name: str + price: float + logger = logging.getLogger("myapp") logger.setLevel(logging.DEBUG) @@ -172,11 +179,114 @@ def health_check(): def root(): return {"message": "Welcome to the Pulse OTel FastAPI agent!"} +# @pulse_agent("getdata") +@app.post("/getdata") +def cftocf_endpoint(request: Request, body: Item): + """ + This is the target endpoint that myapp will call. + It processes Item data and returns a response. + """ + try: + logger.info(f"Received getdata request for item: {body.name} with id: {body.id}") + + # Process the item (you can add your business logic here) + processed_data = { + "message": f"Successfully processed item: {body.name}", + "item_id": body.id, + "item_name": body.name, + "item_price": body.price, + "processed_at": datetime.datetime.now().isoformat(), + "status": "success" + } + + logger.info(f"Successfully processed item {body.id}") + return processed_data + + except Exception as e: + logger.error(f"Error in cftocf endpoint: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to process item: {str(e)}") + +def http_req(body: Item): + """ + Makes an HTTP request to the myapp_2 service at the /cftocf endpoint. + """ + url = "http://myapp:8000/go_py_py" + + data = { + "name": body.name, + "price": body.price, + "id": body.id + } + + # Set headers + headers = { + "Content-Type": "application/json" + } + + try: + # Make the POST request with timeout + response = requests.post(url, json=data, headers=headers, timeout=30) + + # Check if the request was successful + response.raise_for_status() + + # Log successful response + logger.info(f"HTTP request to myapp_2 successful. Status Code: {response.status_code}") + + return { + "status": "success", + "status_code": response.status_code, + "response": response.json() if response.headers.get('content-type', '').startswith('application/json') else response.text + } + + except requests.exceptions.Timeout: + logger.error("HTTP request to myapp_2 timed out") + raise HTTPException(status_code=504, detail="Request to myapp_2 service timed out") + + except requests.exceptions.ConnectionError: + logger.error("Failed to connect to myapp_2 service") + raise HTTPException(status_code=502, detail="Failed to connect to myapp_2 service") + + except requests.exceptions.HTTPError as e: + logger.error(f"HTTP error occurred when calling myapp_2: {e}") + raise HTTPException(status_code=response.status_code, detail=f"myapp_2 service error: {response.text}") + + except requests.exceptions.RequestException as e: + logger.error(f"Request error occurred when calling myapp_2: {e}") + raise HTTPException(status_code=500, detail="Failed to make request to myapp_2 service") + +# @pulse_agent("getdata") +@app.post("/go_py_py") +def cftocf_endpoint(request: Request, body: Item): + """ + This is the target endpoint that myapp will call. + It processes Item data and returns a response. + """ + try: + logger.info(f"Received go_py_py request for item: {body.name} with id: {body.id}") + + # Process the item (you can add your business logic here) + processed_data = { + "id": body.id, + "name": body.name, + "price": body.price, + } + + logger.info(f"Successfully processed item {body.id}") + + + + return http_req(body) + + except Exception as e: + logger.error(f"Error in cftocf endpoint: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to process item: {str(e)}") + def main(): # write to otel collector - _ = Pulse( - otel_collector_endpoint="http://otel-collector:4317", - ) + # _ = Pulse( + # otel_collector_endpoint="http://otel-collector:4317", + # ) # Create a FastAPI app uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/config.go b/examples/otel-collector/pulse_go_otel_instrumentor/config.go new file mode 100644 index 0000000..90e6ed3 --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/config.go @@ -0,0 +1,246 @@ +package pulse_otel + +import ( + "context" + "fmt" + "sync" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +// Config holds OpenTelemetry configuration +type Config struct { + ServiceName string + ServiceVersion string + Environment string + Headers map[string]string + Timeout time.Duration + ResourceAttributes map[string]string +} + +// DefaultConfig returns a default configuration +func DefaultConfig() *Config { + return &Config{ + ServiceName: "default-service", + ServiceVersion: "1.0.0", + Environment: "development", + Headers: make(map[string]string), + Timeout: 30 * time.Second, + ResourceAttributes: make(map[string]string), + } +} + +// AddResourceAttribute adds a resource attribute to the configuration +func (c *Config) AddResourceAttribute(key, value string) { + if c.ResourceAttributes == nil { + c.ResourceAttributes = make(map[string]string) + } + c.ResourceAttributes[key] = value +} + +// AddHeader adds a header for the OTLP exporter +func (c *Config) AddHeader(key, value string) { + if c.Headers == nil { + c.Headers = make(map[string]string) + } + c.Headers[key] = value +} + +type ProjectTraceProvider struct { + traceProvider *trace.TracerProvider + collectorEndpointURL string // URL of the OTLP collector for this project + isCollectorReachable bool + mutex sync.RWMutex // Protects isCollectorReachable +} + +// PulseTraceManager manages OpenTelemetry providers for multiple projects +type PulseTraceManager struct { + projectTraceProviders map[string]*ProjectTraceProvider + baseConfig *Config + mutex sync.RWMutex +} + +// NewPulseTraceManager creates a new pulse trace manager +func NewPulseTraceManager(baseConfig *Config) *PulseTraceManager { + if baseConfig == nil { + baseConfig = DefaultConfig() + } + + return &PulseTraceManager{ + projectTraceProviders: make(map[string]*ProjectTraceProvider), + baseConfig: baseConfig, + } +} + +// IsCollectorReachable safely reads the collector reachability status +func (ptp *ProjectTraceProvider) IsCollectorReachable() bool { + ptp.mutex.RLock() + defer ptp.mutex.RUnlock() + return ptp.isCollectorReachable +} + +// SetCollectorReachable safely updates the collector reachability status +func (ptp *ProjectTraceProvider) SetCollectorReachable(reachable bool) { + ptp.mutex.Lock() + defer ptp.mutex.Unlock() + ptp.isCollectorReachable = reachable +} + +// CheckAndUpdateCollectorReachability checks if collector is reachable and updates the status +func (tm *PulseTraceManager) CheckAndUpdateCollectorReachability(projectID string) (bool, error) { + provider, err := tm.GetTracerProvider(projectID) + if err != nil { + return false, err + } + + // Check current status first + currentStatus := provider.IsCollectorReachable() + + // If already marked as reachable, return without checking again to avoid overhead + if currentStatus { + return true, nil + } + + // Check actual reachability + isReachable := isReachable(provider.collectorEndpointURL, 3*time.Second) + + // Update the status + provider.SetCollectorReachable(isReachable) + + return isReachable, nil +} + +// GetTracerProvider returns or creates a tracer provider for a specific project +func (tm *PulseTraceManager) GetTracerProvider(projectID string) (*ProjectTraceProvider, error) { + tm.mutex.RLock() + provider, exists := tm.projectTraceProviders[projectID] + tm.mutex.RUnlock() + + if exists { + return provider, nil + } + + tm.mutex.Lock() + defer tm.mutex.Unlock() + + // Double-check after acquiring write lock + if provider, exists := tm.projectTraceProviders[projectID]; exists { + return provider, nil + } + + // Create new provider for project + provider, err := tm.createProjectTraceProvider(projectID) + if err != nil { + return nil, fmt.Errorf("failed to create provider for project %s: %w", projectID, err) + } + + tm.projectTraceProviders[projectID] = provider + return provider, nil +} + +func (tm *PulseTraceManager) createProjectTraceProvider(projectID string) (*ProjectTraceProvider, error) { + ctx := context.Background() + + // Create project-specific resource + res, err := tm.createProjectTraceResource(projectID) + if err != nil { + return nil, fmt.Errorf("failed to create resource: %w", err) + } + + // Form project-specific collector endpoint + // collectorEndpointURL := strings.Replace(OTEL_COLLECTOR_ENDPOINT, "{PROJECTID_PLACEHOLDER}", projectID, 1) + collectorEndpointURL := "otel-collector:4318" + + isCollectorReachable := isReachable(collectorEndpointURL, 3*time.Second) + + // Create OTLP HTTP exporter for this project + exporter, err := otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(collectorEndpointURL), + otlptracehttp.WithHeaders(tm.baseConfig.Headers), + otlptracehttp.WithTimeout(tm.baseConfig.Timeout), + otlptracehttp.WithInsecure(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create trace exporter: %w", err) + } + + // Create tracer provider + provider := trace.NewTracerProvider( + trace.WithBatcher(exporter), + trace.WithResource(res), + trace.WithSampler(trace.AlwaysSample()), + ) + + return &ProjectTraceProvider{ + traceProvider: provider, + isCollectorReachable: isCollectorReachable, // Assume reachable initially + collectorEndpointURL: collectorEndpointURL, + }, nil +} + +func (tm *PulseTraceManager) createProjectTraceResource(projectID string) (*resource.Resource, error) { + // Start with base attributes + attributes := []attribute.KeyValue{ + semconv.ServiceName(tm.baseConfig.ServiceName), + semconv.ServiceVersion(tm.baseConfig.ServiceVersion), + semconv.DeploymentEnvironment(tm.baseConfig.Environment), + attribute.String("project.id", projectID), + } + + // Add custom resource attributes + for key, value := range tm.baseConfig.ResourceAttributes { + attributes = append(attributes, attribute.String(key, value)) + } + + return resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + attributes..., + ), + ) +} + +// Shutdown gracefully shuts down all project providers +func (tm *PulseTraceManager) Shutdown(ctx context.Context) error { + tm.mutex.Lock() + defer tm.mutex.Unlock() + + var errors []error + for projectID, provider := range tm.projectTraceProviders { + if provider.traceProvider != nil { + if err := provider.traceProvider.Shutdown(ctx); err != nil { + errors = append(errors, fmt.Errorf("failed to shutdown trace provider for project %s: %w", projectID, err)) + } + } + } + + if len(errors) > 0 { + return fmt.Errorf("shutdown errors: %v", errors) + } + + return nil +} + +// GetTracer returns a project-specific tracer +func (tm *PulseTraceManager) GetTracer(projectID, tracerName string) (*Tracer, error) { + provider, err := tm.GetTracerProvider(projectID) + if err != nil { + return nil, err + } + + if provider.traceProvider == nil { + return nil, fmt.Errorf("trace provider for project %s is not initialized", projectID) + } + + tracer := provider.traceProvider.Tracer(tracerName) + return &Tracer{ + tracer: tracer, + name: tracerName, + }, nil +} diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/const.go b/examples/otel-collector/pulse_go_otel_instrumentor/const.go new file mode 100644 index 0000000..30796cc --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/const.go @@ -0,0 +1,3 @@ +package pulse_otel + +const OTEL_COLLECTOR_ENDPOINT = "otel-collector-{PROJECTID_PLACEHOLDER}.observability.svc.cluster.local:4317" diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/go.mod b/examples/otel-collector/pulse_go_otel_instrumentor/go.mod new file mode 100644 index 0000000..9d93888 --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/go.mod @@ -0,0 +1,30 @@ +module github.com/aanshu-ss/s2-otel-instrumentation-go + +go 1.22 + +toolchain go1.24.4 + +require ( + go.opentelemetry.io/otel v1.30.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 + go.opentelemetry.io/otel/sdk v1.30.0 + go.opentelemetry.io/otel/trace v1.30.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect + go.opentelemetry.io/otel/metric v1.30.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.66.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/go.sum b/examples/otel-collector/pulse_go_otel_instrumentor/go.sum new file mode 100644 index 0000000..dab23ef --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/go.sum @@ -0,0 +1,49 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= +go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= +go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w= +go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ= +go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE= +go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= +go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc= +go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM= +google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/main/main.go b/examples/otel-collector/pulse_go_otel_instrumentor/main/main.go new file mode 100644 index 0000000..dad8527 --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/main/main.go @@ -0,0 +1,312 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "time" + + pulse_otel "github.com/aanshu-ss/s2-otel-instrumentation-go" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +var ( + middleware *pulse_otel.HTTPMiddleware // Only keep the middleware as global +) + +func main() { + // Initialize OpenTelemetry base configuration + config := pulse_otel.DefaultConfig() + config.ServiceName = "user-api" + config.ServiceVersion = "1.0.0" + config.Environment = "development" + config.AddResourceAttribute("api.type", "rest") + config.AddResourceAttribute("team", "backend") + + // Create HTTP middleware with project support (this creates project manager internally) + middleware = pulse_otel.NewHTTPMiddleware("user-api", config) + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + middleware.Shutdown(ctx) // Shutdown through middleware + }() + + // Setup routes with instrumentation + http.Handle("/users", middleware.Handler(http.HandlerFunc(getUsersHandler))) + http.Handle("/users/create", middleware.Handler(http.HandlerFunc(createUserHandler))) + http.Handle("/health", middleware.Handler(http.HandlerFunc(healthHandler))) + http.Handle("/my-post", middleware.Handler(http.HandlerFunc(myPostHandler))) + + log.Println("Starting server on :8093") + log.Fatal(http.ListenAndServe(":8093", nil)) +} + +func getUsersHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get project-specific tracer + projectID := r.Header.Get("x-project-id") + if projectID == "" { + projectID = "default" + } + + tracer, err := middleware.GetPulseTraceManager().GetTracer(projectID, "user-api") + if err != nil { + log.Printf("Error getting tracer for project %s: %v", projectID, err) + writeErrorResponse(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Add custom span attributes + tracer.AddSpanAttributes(ctx, + attribute.String("handler.name", "getUsers"), + attribute.String("operation.type", "read"), + attribute.String("project.id", projectID), + ) + + // Simulate database call with child span + users, err := fetchUsersFromDB(ctx, tracer) + if err != nil { + tracer.RecordError(ctx, err) + writeErrorResponse(w, "Failed to fetch users", http.StatusInternalServerError) + return + } + + tracer.AddSpanAttribute(ctx, "users.count", strconv.Itoa(len(users))) + writeSuccessResponse(w, users) +} + +func createUserHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get project-specific tracer + projectID := r.Header.Get("x-project-id") + if projectID == "" { + projectID = "default" + } + + tracer, err := middleware.GetPulseTraceManager().GetTracer(projectID, "user-api") + if err != nil { + log.Printf("Error getting tracer for project %s: %v", projectID, err) + writeErrorResponse(w, "Internal server error", http.StatusInternalServerError) + return + } + + var user User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Add request attributes + tracer.AddSpanAttributes(ctx, + attribute.String("user.name", user.Name), + attribute.String("user.email", user.Email), + attribute.String("project.id", projectID), + ) + + // Create user in database + createdUser, err := createUserInDB(ctx, user, tracer) + if err != nil { + tracer.RecordError(ctx, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Add response attributes + tracer.AddSpanAttributes(ctx, + attribute.String("created.user_id", createdUser.ID), + attribute.String("response.status", "created"), + ) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(createdUser) +} + +// Update database functions to accept tracer parameter +func fetchUsersFromDB(ctx context.Context, tracer *pulse_otel.Tracer) ([]User, error) { + return pulse_otel.WithSpanReturnTyped(tracer, ctx, "db.fetch_users", func(ctx context.Context) ([]User, error) { + tracer.AddSpanAttributes(ctx, + attribute.String("db.operation", "SELECT"), + attribute.String("db.table", "users"), + ) + + // Simulate database latency + time.Sleep(50 * time.Millisecond) + + users := []User{ + {ID: "1", Name: "John Doe", Email: "john@example.com"}, + {ID: "2", Name: "Jane Smith", Email: "jane@example.com"}, + } + + tracer.AddSpanAttribute(ctx, "db.rows_affected", strconv.Itoa(len(users))) + return users, nil + }) +} + +func createUserInDB(ctx context.Context, user User, tracer *pulse_otel.Tracer) (User, error) { + return pulse_otel.WithSpanReturnTyped(tracer, ctx, "db.create_user", func(ctx context.Context) (User, error) { + tracer.AddSpanAttributes(ctx, + attribute.String("db.operation", "INSERT"), + attribute.String("db.table", "users"), + attribute.String("user.email", user.Email), + ) + + // Simulate database write latency + time.Sleep(100 * time.Millisecond) + + user.ID = fmt.Sprintf("user_%d", time.Now().Unix()) + tracer.AddSpanAttribute(ctx, "user.id", user.ID) + return user, nil + }) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get project-specific tracer + projectID := r.Header.Get("x-project-id") + if projectID == "" { + projectID = "default" + } + + tracer, err := middleware.GetPulseTraceManager().GetTracer(projectID, "user-api") + if err != nil { + log.Printf("Error getting tracer for project %s: %v", projectID, err) + writeErrorResponse(w, "Internal server error", http.StatusInternalServerError) + return + } + + tracer.AddSpanAttribute(ctx, "handler.name", "health") + + response := map[string]interface{}{ + "status": "healthy", + "timestamp": time.Now().UTC(), + "service": "user-api", + } + + writeSuccessResponse(w, response) +} + +func writeSuccessResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Response{ + Success: true, + Data: data, + }) +} + +func writeErrorResponse(w http.ResponseWriter, message string, statusCode int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(Response{ + Success: false, + Error: message, + }) +} + +func myPostHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Debug: Check if we have an active span + span := trace.SpanFromContext(ctx) + fmt.Printf("Handler: Active span found: %t, Recording: %t\n", span != nil, span.IsRecording()) + + // Get project-specific tracer + projectID := r.Header.Get("x-project-id") + if projectID == "" { + projectID = "default" + } + + tracer, err := middleware.GetPulseTraceManager().GetTracer(projectID, "user-api") + if err != nil { + log.Printf("Error getting tracer for project %s: %v", projectID, err) + writeErrorResponse(w, "Internal server error", http.StatusInternalServerError) + return + } + + tracer.AddSpanAttribute(ctx, "handler.name", "createHandlerFunc") + + url := "http://myapp_2:8000/go_py_py" + + var reqBody struct { + Name string `json:"name"` + Price string `json:"price"` + ID string `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + writeErrorResponse(w, "Invalid request body", http.StatusBadRequest) + return + } + + newBody, err := json.Marshal(map[string]string{ + "name": reqBody.Name, + "price": reqBody.Price, + "id": reqBody.ID, + }) + if err != nil { + tracer.RecordError(ctx, err) + writeErrorResponse(w, "Failed to marshal request body", http.StatusInternalServerError) + return + } + + // Simulate a POST request to the given URL using instrumented HTTP client + + // Create request with context to ensure trace propagation + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(newBody)) + if err != nil { + tracer.RecordError(ctx, err) + writeErrorResponse(w, "Failed to create request", http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "application/json") + + fmt.Printf("Handler: Making HTTP request to %s\n", url) + // Use instrumented HTTP client for automatic span creation and propagation + resp, err := pulse_otel.GetInstrumentedHTTPClient().Do(req) + fmt.Printf("Handler: HTTP request completed, status: %v, error: %v\n", + func() string { + if resp != nil { + return fmt.Sprintf("%d", resp.StatusCode) + } else { + return "nil" + } + }(), err) + if err != nil { + tracer.RecordError(ctx, err) + writeErrorResponse(w, "Failed to make request", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + w.WriteHeader(resp.StatusCode) + json.NewEncoder(w).Encode(Response{ + Success: true, + Data: fmt.Sprintf("Request made to %s with status %d", url, resp.StatusCode), + }) +} diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/middleware.go b/examples/otel-collector/pulse_go_otel_instrumentor/middleware.go new file mode 100644 index 0000000..0eee7f5 --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/middleware.go @@ -0,0 +1,291 @@ +package pulse_otel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" +) + +// HTTPMiddleware provides HTTP instrumentation middleware +type HTTPMiddleware struct { + pulseTraceManager *PulseTraceManager + serviceName string +} + +// NewHTTPMiddleware creates a new HTTP middleware with project support +func NewHTTPMiddleware(serviceName string, baseConfig *Config) *HTTPMiddleware { + // Set up global OpenTelemetry providers so instrumented libraries can use them + setupGlobalOTelProviders(baseConfig) + + return &HTTPMiddleware{ + pulseTraceManager: NewPulseTraceManager(baseConfig), + serviceName: serviceName, + } +} + +// setupGlobalOTelProviders configures global OpenTelemetry providers +func setupGlobalOTelProviders(baseConfig *Config) { + // Create a default tracer provider for global use + defaultProvider, err := NewPulseTraceManager(baseConfig).GetTracerProvider("default") + if err == nil { + // Set the global tracer provider so instrumented libraries can use it + otel.SetTracerProvider(defaultProvider.traceProvider) + } + + // Set the global text map propagator for context propagation + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) +} + +// GetPulseTraceManager returns the pulse trace manager instance +func (m *HTTPMiddleware) GetPulseTraceManager() *PulseTraceManager { + return m.pulseTraceManager +} + +// Shutdown gracefully shuts down the middleware and its project manager +func (m *HTTPMiddleware) Shutdown(ctx context.Context) error { + return m.pulseTraceManager.Shutdown(ctx) +} + +// Handler wraps an http.Handler with opentelemetry instrumentation +func (m *HTTPMiddleware) Handler(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract project ID from header first + projectID := r.Header.Get("singlestore-project-id") + + // If not found in header, check JSON body + if projectID == "" { + projectID = m.extractProjectIDFromBody(r) + } + + fmt.Println("Project ID extracted:", projectID) + + // Get project-specific tracer provider + provider, err := m.pulseTraceManager.GetTracerProvider(projectID) + if err != nil { + // Log error and use default behavior + fmt.Printf("Error getting project provider for %s: %v\n", projectID, err) + handler.ServeHTTP(w, r) + return + } + + // Check and update collector reachability safely + isCollectorReachable, err := m.pulseTraceManager.CheckAndUpdateCollectorReachability(projectID) + if err != nil { + fmt.Printf("Error checking collector reachability for project %s: %v\n", projectID, err) + handler.ServeHTTP(w, r) + return + } + + fmt.Println("isCollectorReachable:", isCollectorReachable) + if !isCollectorReachable { + // If collector is not reachable, skip tracing + handler.ServeHTTP(w, r) + return + } + + tracer := provider.traceProvider.Tracer(m.serviceName) + + // Extract any existing trace context from incoming request headers + ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header)) + + spanName := fmt.Sprintf("%s %s", r.Method, r.URL.Path) + ctx, span := tracer.Start(ctx, spanName, + trace.WithSpanKind(trace.SpanKindServer), + trace.WithAttributes( + semconv.HTTPRoute(r.URL.Path), + attribute.String("project.id", projectID), + ), + ) + defer span.End() + + // IMPORTANT: Set the global tracer provider to the project-specific one + // This ensures that any instrumented library will use the correct tracer provider + otel.SetTracerProvider(provider.traceProvider) + + wrappedWriter := &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + } + + // Execute the handler with the instrumented context + // The context now contains the active span that instrumented libraries can use + r = r.WithContext(ctx) + start := time.Now() + handler.ServeHTTP(wrappedWriter, r) + duration := time.Since(start) + + // Add response attributes + span.SetAttributes( + attribute.Float64("http.duration_ms", float64(duration.Nanoseconds())/1000000), + ) + + // Set span status based on HTTP status code + if wrappedWriter.statusCode >= 400 { + span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", wrappedWriter.statusCode)) + } + + // Inject trace context into response headers + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(w.Header())) + }) +} + +// extractProjectIDFromBody attempts to extract project-id from the request body +func (m *HTTPMiddleware) extractProjectIDFromBody(r *http.Request) string { + // Only check for project-id in POST/PUT/PATCH requests with JSON content + if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodPatch { + return "" + } + + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" && contentType != "application/json; charset=utf-8" { + return "" + } + + // Read the body + body, err := io.ReadAll(r.Body) + if err != nil { + return "" + } + + // Restore the body for the actual handler to use + r.Body = io.NopCloser(bytes.NewBuffer(body)) + + // Parse JSON to extract project-id + var jsonData map[string]interface{} + if err := json.Unmarshal(body, &jsonData); err != nil { + return "" + } + + // Extract project-id from JSON + if projectID, exists := jsonData["project-id"]; exists { + if projectIDStr, ok := projectID.(string); ok { + return projectIDStr + } + } + + return "" +} + +func (m *HTTPMiddleware) HandlerFunc(handler http.HandlerFunc) http.HandlerFunc { + return m.Handler(handler).ServeHTTP +} + +// responseWriter wraps http.ResponseWriter to capture response details +type responseWriter struct { + http.ResponseWriter + statusCode int + bytesWritten int64 +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(data []byte) (int, error) { + n, err := rw.ResponseWriter.Write(data) + rw.bytesWritten += int64(n) + return n, err +} + +// GetInstrumentedHTTPClient returns an HTTP client that will automatically +// create spans for outgoing requests when used within a traced context +func GetInstrumentedHTTPClient() *http.Client { + return &http.Client{ + Transport: &InstrumentedTransport{ + base: http.DefaultTransport, + }, + } +} + +// InstrumentedTransport wraps http.RoundTripper to add automatic tracing +type InstrumentedTransport struct { + base http.RoundTripper +} + +// RoundTrip implements http.RoundTripper and automatically creates spans for HTTP requests +func (t *InstrumentedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + + // Debug: Print context information + fmt.Printf("HTTP Client: Request URL: %s\n", req.URL.String()) + + // Get the active span from context (if any) + span := trace.SpanFromContext(ctx) + fmt.Printf("HTTP Client: Active span found: %t, Recording: %t\n", span != nil, span.IsRecording()) + + if !span.IsRecording() { + // No active span, just pass through + fmt.Println("HTTP Client: No recording span, passing through") + return t.base.RoundTrip(req) + } + + // Get tracer from the global tracer provider + tracer := otel.Tracer("http-client") + + // Create a new span for the HTTP request + spanName := fmt.Sprintf("HTTP %s %s", req.Method, req.URL.Host) + ctx, clientSpan := tracer.Start(ctx, spanName, + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("http.method", req.Method), + attribute.String("http.url", req.URL.String()), + attribute.String("http.scheme", req.URL.Scheme), + attribute.String("http.host", req.URL.Host), + attribute.String("http.target", req.URL.Path), + ), + ) + defer clientSpan.End() + + fmt.Printf("HTTP Client: Created client span: %s\n", spanName) + + // Inject trace context into request headers for downstream propagation + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)) + + // Update request with new context + req = req.WithContext(ctx) + + // Make the request + start := time.Now() + resp, err := t.base.RoundTrip(req) + duration := time.Since(start) + + // Add response attributes + clientSpan.SetAttributes( + attribute.Float64("http.duration_ms", float64(duration.Nanoseconds())/1000000), + ) + + if err != nil { + clientSpan.RecordError(err) + clientSpan.SetStatus(codes.Error, err.Error()) + return resp, err + } + + // Add response status + clientSpan.SetAttributes( + attribute.Int("http.status_code", resp.StatusCode), + ) + + // Set span status based on HTTP status code + if resp.StatusCode >= 400 { + clientSpan.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", resp.StatusCode)) + } else { + clientSpan.SetStatus(codes.Ok, "") + } + + return resp, nil +} diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/tracer.go b/examples/otel-collector/pulse_go_otel_instrumentor/tracer.go new file mode 100644 index 0000000..43e9068 --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/tracer.go @@ -0,0 +1,183 @@ +package pulse_otel + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Tracer wraps OpenTelemetry tracer with additional utilities +type Tracer struct { + tracer trace.Tracer + name string +} + +func NewTracer(name string) *Tracer { + return &Tracer{ + tracer: otel.Tracer(name), + name: name, + } +} + +// StartSpan starts a new span with the given name +func (t *Tracer) StartSpan(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return t.tracer.Start(ctx, spanName, opts...) +} + +// StartSpanWithParent starts a new span with a parent span context +func (t *Tracer) StartSpanWithParent(parentCtx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return t.tracer.Start(parentCtx, spanName, opts...) +} + +// AddSpanAttributes adds attributes to the current span in context +func (t *Tracer) AddSpanAttributes(ctx context.Context, attributes ...attribute.KeyValue) { + span := trace.SpanFromContext(ctx) + if span != nil { + span.SetAttributes(attributes...) + } +} + +// AddSpanAttribute adds a single attribute to the current span in context +func (t *Tracer) AddSpanAttribute(ctx context.Context, key, value string) { + t.AddSpanAttributes(ctx, attribute.String(key, value)) +} + +// RecordError records an error in the current span +func (t *Tracer) RecordError(ctx context.Context, err error, opts ...trace.EventOption) { + span := trace.SpanFromContext(ctx) + if span != nil { + span.RecordError(err, opts...) + span.SetStatus(codes.Error, err.Error()) + } +} + +// SetSpanStatus sets the status of the current span +func (t *Tracer) SetSpanStatus(ctx context.Context, code codes.Code, description string) { + span := trace.SpanFromContext(ctx) + if span != nil { + span.SetStatus(code, description) + } +} + +// FinishSpan safely finishes a span +func (t *Tracer) FinishSpan(span trace.Span) { + if span != nil { + span.End() + } +} + +// WithSpan executes a function within a new span +func (t *Tracer) WithSpan(ctx context.Context, spanName string, fn func(context.Context) error, opts ...trace.SpanStartOption) error { + ctx, span := t.StartSpan(ctx, spanName, opts...) + defer span.End() + + if err := fn(ctx); err != nil { + t.RecordError(ctx, err) + return err + } + + return nil +} + +// GetTraceID returns the trace ID from the current context +func (t *Tracer) GetTraceID(ctx context.Context) string { + span := trace.SpanFromContext(ctx) + if span != nil { + return span.SpanContext().TraceID().String() + } + return "" +} + +// GetSpanID returns the span ID from the current context +func (t *Tracer) GetSpanID(ctx context.Context) string { + span := trace.SpanFromContext(ctx) + if span != nil { + return span.SpanContext().SpanID().String() + } + return "" +} + +// CreateChildSpan creates a child span from the current context +func (t *Tracer) CreateChildSpan(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return t.tracer.Start(ctx, spanName, opts...) +} + +// WithSpanReturn executes a function within a new span and returns a value +func (t *Tracer) WithSpanReturn(ctx context.Context, spanName string, fn func(context.Context) (interface{}, error), opts ...trace.SpanStartOption) (interface{}, error) { + ctx, span := t.StartSpan(ctx, spanName, opts...) + defer span.End() + + result, err := fn(ctx) + if err != nil { + t.RecordError(ctx, err) + return result, err + } + + return result, nil +} + +// WithSpanReturnTyped executes a function within a new span and returns a typed value +func WithSpanReturnTyped[T any](t *Tracer, ctx context.Context, spanName string, fn func(context.Context) (T, error), opts ...trace.SpanStartOption) (T, error) { + ctx, span := t.StartSpan(ctx, spanName, opts...) + defer span.End() + + result, err := fn(ctx) + if err != nil { + t.RecordError(ctx, err) + return result, err + } + + return result, nil +} + +// SpanHelper provides convenient span operations +type SpanHelper struct { + span trace.Span + ctx context.Context +} + +// NewSpanHelper creates a new span helper +func NewSpanHelper(ctx context.Context, span trace.Span) *SpanHelper { + return &SpanHelper{ + span: span, + ctx: ctx, + } +} + +// AddAttribute adds an attribute to the span +func (s *SpanHelper) AddAttribute(key, value string) *SpanHelper { + s.span.SetAttributes(attribute.String(key, value)) + return s +} + +// AddAttributes adds multiple attributes to the span +func (s *SpanHelper) AddAttributes(attrs ...attribute.KeyValue) *SpanHelper { + s.span.SetAttributes(attrs...) + return s +} + +// SetStatus sets the span status +func (s *SpanHelper) SetStatus(code codes.Code, description string) *SpanHelper { + s.span.SetStatus(code, description) + return s +} + +// RecordError records an error +func (s *SpanHelper) RecordError(err error) *SpanHelper { + s.span.RecordError(err) + s.span.SetStatus(codes.Error, err.Error()) + return s +} + +// End ends the span +func (s *SpanHelper) End() { + s.span.End() +} + +// Context returns the span context +func (s *SpanHelper) Context() context.Context { + return s.ctx +} diff --git a/examples/otel-collector/pulse_go_otel_instrumentor/utils.go b/examples/otel-collector/pulse_go_otel_instrumentor/utils.go new file mode 100644 index 0000000..6f9c5df --- /dev/null +++ b/examples/otel-collector/pulse_go_otel_instrumentor/utils.go @@ -0,0 +1,25 @@ +package pulse_otel + +import ( + "net" + "net/url" + "time" +) + +// isReachable checks if the host:port endpoint is reachable within the timeout +func isReachable(endpoint string, timeout time.Duration) bool { + conn, err := net.DialTimeout("tcp", endpoint, timeout) + if err != nil { + return false + } + defer conn.Close() + return true +} + +func stripScheme(fullURL string) (string, error) { + parsed, err := url.Parse(fullURL) + if err != nil { + return "", err + } + return parsed.Host, nil +} diff --git a/examples/otel-collector/requirements.txt b/examples/otel-collector/requirements.txt index 0af110b..1ebfa48 100644 --- a/examples/otel-collector/requirements.txt +++ b/examples/otel-collector/requirements.txt @@ -1,3 +1,8 @@ fastapi uvicorn +python-dotenv +openai +requests +tenacity +pydantic From 9575730377f5c9ce0a71cfafe0485c3a5f7f7306 Mon Sep 17 00:00:00 2001 From: Ashutosh Anshu Date: Thu, 10 Jul 2025 18:00:41 +0530 Subject: [PATCH 2/2] Update Dockerfile to use local wheel file and enable observability in myapp_2.py --- dist/singlestore_pulse-0.3-py3-none-any.whl | Bin 10907 -> 10517 bytes dist/singlestore_pulse-0.3.tar.gz | Bin 11889 -> 11322 bytes examples/otel-collector/Dockerfile.myapp_2 | 4 +- examples/otel-collector/myapp.py | 6 +- examples/otel-collector/myapp_2.py | 13 ++-- .../singlestore_pulse-0.3-py3-none-any.whl | Bin 0 -> 10517 bytes pulse_otel/main.py | 56 +++++------------- 7 files changed, 29 insertions(+), 50 deletions(-) create mode 100644 examples/otel-collector/singlestore_pulse-0.3-py3-none-any.whl diff --git a/dist/singlestore_pulse-0.3-py3-none-any.whl b/dist/singlestore_pulse-0.3-py3-none-any.whl index 6abf3181ab81f0b516dabc4f3496bdfc39b38b01..54547841526c1d3c75af1329e64fe6f09bfa0b15 100644 GIT binary patch delta 6256 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 Fe*h|So~r-= delta 6641 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 diff --git a/dist/singlestore_pulse-0.3.tar.gz b/dist/singlestore_pulse-0.3.tar.gz index ce9a4a80a802e8057cfcb93f5250a01b9add4ae5..f33842d2420023966d490fc6ce0d11f7cd0b3da7 100644 GIT binary patch literal 11322 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 literal 11889 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 L>jWVI3Gx2`B79s> diff --git a/examples/otel-collector/Dockerfile.myapp_2 b/examples/otel-collector/Dockerfile.myapp_2 index 4846ca8..e828252 100644 --- a/examples/otel-collector/Dockerfile.myapp_2 +++ b/examples/otel-collector/Dockerfile.myapp_2 @@ -5,7 +5,9 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -RUN pip3 install git+https://github.com/singlestore-labs/singlestore-pulse.git@master +COPY singlestore_pulse-0.3-py3-none-any.whl . +RUN pip install --no-cache-dir singlestore_pulse-0.3-py3-none-any.whl +# RUN pip3 install git+https://github.com/singlestore-labs/singlestore-pulse.git@master COPY myapp_2.py . COPY .env . diff --git a/examples/otel-collector/myapp.py b/examples/otel-collector/myapp.py index f656d5c..7061ff4 100644 --- a/examples/otel-collector/myapp.py +++ b/examples/otel-collector/myapp.py @@ -326,9 +326,9 @@ def cftocf_endpoint(request: Request, body: Item): def main(): # write to otel collector - _ = Pulse( - otel_collector_endpoint="http://otel-collector:4317", - ) + # _ = Pulse( + # otel_collector_endpoint="http://otel-collector:4317", + # ) # Create a FastAPI app uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/otel-collector/myapp_2.py b/examples/otel-collector/myapp_2.py index b77b840..4369f27 100644 --- a/examples/otel-collector/myapp_2.py +++ b/examples/otel-collector/myapp_2.py @@ -13,7 +13,7 @@ from opentelemetry.sdk._logs import LoggingHandler -from pulse_otel import Pulse, pulse_agent, pulse_tool +from pulse_otel import Pulse, pulse_agent, pulse_tool, observe import logging from tenacity import retry, stop_after_attempt, wait_fixed @@ -179,7 +179,7 @@ def health_check(): def root(): return {"message": "Welcome to the Pulse OTel FastAPI agent!"} -# @pulse_agent("getdata") +@pulse_agent("getdata") @app.post("/getdata") def cftocf_endpoint(request: Request, body: Item): """ @@ -255,8 +255,9 @@ def http_req(body: Item): logger.error(f"Request error occurred when calling myapp_2: {e}") raise HTTPException(status_code=500, detail="Failed to make request to myapp_2 service") -# @pulse_agent("getdata") + @app.post("/go_py_py") +@observe("cftocf_endpoint") def cftocf_endpoint(request: Request, body: Item): """ This is the target endpoint that myapp will call. @@ -284,9 +285,9 @@ def cftocf_endpoint(request: Request, body: Item): def main(): # write to otel collector - # _ = Pulse( - # otel_collector_endpoint="http://otel-collector:4317", - # ) + _ = Pulse( + otel_collector_endpoint="http://otel-collector:4317", + ) # Create a FastAPI app uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/otel-collector/singlestore_pulse-0.3-py3-none-any.whl b/examples/otel-collector/singlestore_pulse-0.3-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..54547841526c1d3c75af1329e64fe6f09bfa0b15 GIT binary patch literal 10517 zcma)?bx>W~vac8J7A&~C1`Y1+?y`a|+}+)S1ZUwMg1ZKHCqQs_cMUK5-23j{XTS66 z?m27BS@Vzn>Q^;;bdTz8WjQElEC2ui4+vH{(uDz;aBBYkvi-eqelG`C8)q{kdlxes z79%5&9mvJVh}pqYj$vd{gO+xjPBC_pMP__@RAFpPZD@>Xh=pDS9)NI6`!N+Kb!WfT zY_AX&mvXN~GhjzG+67CPT=4M_9wOpHc8qXYAt|3i6dEg!{MQ{x*D1#(Of&a=9@`sP&{g=wYKGW|8C1x#X+ou)L(iaM z%&V2vof7t(3uSSfBek=0<;RLd$Z=-{yBANTD03^%V82qwV` zj>3L`6CRhC(@s9hMaN)nIe99URGSR*GOwB)w#KcB#HdtF>Q5=I_LWt33cc@Jxi;pm zpG|ijq3~OQ2N*eCaj0};{V{S8y?lvs=~3-T)W&XsXX1}zU$zi}-q^sJQ3Y87&Y;EZ z53$4pXMs=*xN_z-`PFlB-Q(D9SY}9vtoWCw(;y|@ToB=o{(g&-a8Tf>ch@PrAPFzV zZr1LhP%(fj)*KftFCXi3^(ZYfnGV;t4oj&#()@oSzQ1Vv^#=k~1A z_#jZqmKs@hVNnC@j8%QY1>RRSm~x|hDh4$!7$gpces5bh5ym=Ty<@iEOqUK!-aB}z4B?EBv zvhzY%QoP>pK%A`gc(+dm>`2L;Ov^p05cgWe$i^m9qwBVD&g zT3|E=0D#B?0KEHmKeq*f?0$RsIN!3}_E7TVjMiK^iM)cTU9C1FBoyLS&8mPbzgw@{ zB=iBtN2(gr4+;soh9~n}$S3wE=clPBEgCf*3Ck0d-|=I)hIw7>T#r`DvuxZwRmxn! z-^%%wMjd-dobJ^ZWNdO~Uw#P=pPgDatZy#24mDj>nC{4zlo=MA7c15Yyxf;2A+8TX z>K`P_T>IdrTw3t$PxuiAAehvRAQ|jRfVyuxhRf4MD?LR!gVCrt_0+Jvk+2~~cOUQo zRTV&;%^x>&2$(Z@&H&*OwMP>PL;Q8RJ z+!xAluq(o-an!xUulT;=v}r!tH#l&AS$od0tWY!qhY@^g?u((;SCp|f5rM$~FS8Lg z3w>N@&`TNL4+Y^MoKu>x~a))R5%XO`yTfffEi1WZ>&TAxc$;X{_cgo+zV|l{5X@04@v;^bo1;uK5 zX5b6!0i@eUF&8zrRla;VEwp3wT(xhtvPm$nVH4>Zh7~X?|%1g>(0&57n^Bfb2mJC_BuM#($UrJ zb$jc`U=H8M-}$v9x{j}}_TeyJ>9;U?XWg6qVyu~X=qK723dRbSU z^82C5VGpd#hNyt5!YFd(xl)cp{62gE92qm^^c_a~xl; zXG{&vQ23Y}G;I+yM!9>H4F2~{qyi^u8@?Rr@*8h3tT_i9k1VWvRwi?rH0ht{NRq4y zGz;hP33;^>Am!?1)i-*iHA<&G81pg(ynC^etAZ>P5K!5Ny<6rQ4w%>;0F*&O29aw^ z5YKLjNjeo_PoQFuywWykBfSYY7`%5F)seVnb44-Snt(woVI)~-(XqbJ7LSY%bI&YP zU*->6RE|nx0GGcXr(3s$@Lo(DXIkZkhVI-Vd(?-gs;^z54={sPcdte-Imbm5B4a^e z^(-!At&yESN)_bpB7Jt37|-A{W)j4^M>))e;4kTD)ZrNhn#J$PZ#MThCcK|FD{|L} zeaU~YOBezg-lb?y(|_-3+ z1_52RkdhBsZqu-eu(VbU<)er}$WbRQf#lnBCYlwl?P$obYTBon9`mOuqbs9omY{oW3tS`$KOp}&-8UWdY{U+YHD!~K zw>Jj4d(Q|e7z<0fs(V#zyOmjLX=_RmTJ+FHC1FEnSFiQQKf@C3t$v(lKtAa~?uWh> zbo{vJN5(qqM=vHdBa%zR72a%&L!obBquRO3pBY4~o%0%+W)%UHq zDNcmIoqY0=8C}`d&kf0Jc}c1Iq#D=IGD*|RsziMYQI(q3M+4zEE`GjXb zHQG{Kr8*QX-jwK`2!roiplLjTp7Ev`GQUCP&J-&3QRckrAzXHhd};SRzw=odoe5n}DpjoR5gB%n8ZYziBNy}}UnC&hcX zN#Yzx{;6>^Y3{Fr^-gqp&IJYHKGxOG|VKkj}&Gu?cL`y{)T{8=WzfHDO z_eI$$?=n&uB~mx?&faen<4M=H$I3(nj^Q~TtuiZURuH(Fb&_i7G%;#Stc&dk59pV6 zhR3GzY?{Y|sK25F$;o5{G##2_M%n6#Mjl$y?{G{N#DpGAOz9r=uEG|}$Hn%$o5r{6 zR3&Ky>EN4WM}7iyEPooi6A%|tdtgJQ{@}K0w1sMTgz}5TyVXCw$)>0W&#J<4*- z%!=2buW&(AJ=ZcJB66uZ=CDUa6(|vG2!C!<|Gkz=s4ygZn#eF6N{{M7C(Z435mZ7I zK@SOJKTa~A`1~Cw5QXvf3zl|8+I94~P`ldPb62nSd95P9e?HLDUuWMT;O(8Y_fe8a zO4qlho}}2b?D6oUj8-yR5&mnt9f!SdGOs?xGmM>S&9oJ`s790#^33}ryN9#u8ganD zTc*^oLcFHeCD}&kELG{CsjFjoXMeejH(+1-OEXpW}y&Q`18u z59H~$2RL9&tUit!pDeW(elG6!7U`vWQq$3Py2Nf#LTDK6AaqbEQ21F_1!q8sRRV$1 z(7{LbtVUn1447+2}UWxSn} zP4fGDjVb0Vuk>X7M)fqmyNWCQ;Qg^PMOg!KS=aDgw31w&XR0D(k=ckN3?AlI7jzt8 zZAh6t*UFvdcZQrgnvszZf0aK@bnx|xRT{XJ1YaT|lRK5oJWzT1&^Y(vMCP=(D&`h% z`}<_vMa^v7hIS*32Kj5MOf@t6rw>%oXRlaUhbl79haPX!SADC24(5)n3b~4Bk;n{J8Yq_I@B{!cJ*rJfe*E zSFm@t#3KdKE-ZW~v`fpRCL)H@u0Avj2}use{?Ize0sG>(ZSwPjIPGcRB?5QIdioev z={T>Paf(N4|6|${uZM1S-+PI2^6$~aWWVmu1F^8|Npq*ca8z)ei}`utq8=)Yq(x19 z5#wQc6yukyxj0kG!k6$IX)n7EIJM=Y(aUYenbF&`{WV#;#4Hot%kq`KZoilSXT3!Y zNm-+Dhq}9I@cJZq(cyGWhEc!oLMX)ED_D+MeXC;BD`lCN-J>cxZn7?s0u3)lansqJ zpdhwD0m*D-2iL!~lknB5-edQ_n4*P>DENiat;7tj(Y#Ryn5u0R?DxR`I zySSky*XJ&_L<6g+v10jd7X9mJ9H}B$EZmE6&f_L;?*7Ez=bPRAjc^B=zPU$}WpG(@ zN>d7IC_?I8%yP3IC#24ZQ7=X&$%IpsiBR-4I)g!fz^ioE1w00_*ZKAM=kuTLtVE4wrT8?y@6zt3Jfrv1B4*^L9bed8Ox;UO zco3njbu>5wt`I(Di`v1o~Xl1P!FhWT<=V&=7QF zb?PBnyv7*jZ9>tXwa~avISTt=Zxjz%FZ#Cv9;L;B7EPIAPSX4$h%KiyA(-Yl{~Pfux%hgE5T5HU>{1U*L6YSEe^Q)Ox!#E=S7<9e3$MI&;nQ*g`<`lxCg-D_Tfcyw>!eU39N@G4?BB>@FrYt zA%hhUe2QfvE;p;Y7ARZmGL$y0mg9xyq#eaW@_jy(6f#OTR5FAjjz?IFRgby#5FfIu z?*=GuFkb=}V)Ssuii-R2_3x?@Nshu>A^4G+CQZV_tTjb~j#a(jEio=NU8W>qLes;1 zAhr%#AqGfz@dqpcM^vV#KuCl`dij7}vSwZzGU4KD0?tQk*r$5q{K7M;i;KJ7pLpsK zCSU6f*NZ)W(J(}}E_7+?EOJG-7O4P3s;jDtDSEYH5Cm^J%ejxfWV90A(z#xv!agy? z0dZTG}KKV4?DNd)aO zOT8BRMqDLzh|Y8(=+i$>WJ_+QG3aF?HO~RqenQ<{%B54V?O_E08Q(XD2%XNH*RwGB z$_qSqOq<$*M$Dw`eyN^SZWYEaLvm0?z!usxDDho=&O8Ho$1Mm@<%_Ytv1uhC#0H^W z$PHCM-(hQu8^A&(J+dk*Ivkj@_348QKjn|#zjmRH`CKuGw!W^o#)$0<*y>`kvSi6U z6(3hMQf#hry8_xtu{cAS-+n?r?}`vFO?bKtv18hoMh+y5B)053?iU~8!}4|Fb{pd! zQ}EYo?`k)VeLk}CeR(d>BAV;?Av2On5f3=tway$%5~#oa3ABv~+a8E~N!Ry?Mu0Jm zBw=|}Y7kU8Z`>wVY|djTeTBXWkn{9zZGLfZ-UK1I+igmuCzxpHYhgSYDsQp5o__yK z^vcPX@#H)YGO!CeWM|Z_x^o9*yxEB{{^GIq7xUBfU&!$qxnZQ$FY*Kl7r#OMS-Rnu zKG0=PJ51?D1OSpr|DV#0s|(2Hcj=}9Z`p47GQ6|l| z;?F%U=E=IXts~AnGO~uC?iIm`={5o2Je4u%j#-XEqx(8;mGM|VS{*NeMoq)AAk+C3 zHF$mZ^np>Jlb?@(WZlrPWIH

*#FsGMR05j8JT6_G-^j)6Me1lgrJejgLuFe$kDO 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 literal 0 HcmV?d00001 diff --git a/pulse_otel/main.py b/pulse_otel/main.py index 3301f60..b4601cc 100644 --- a/pulse_otel/main.py +++ b/pulse_otel/main.py @@ -319,58 +319,34 @@ def wrapper(*args, **kwargs): def observe(name): - """ - A decorator factory that instruments a function for observability using opentelemetry tracing. - Args: - name (str): The name of the span to be created for tracing. - Returns: - Callable: A decorator that wraps the target function, extracting opentelemetry tracing context - from the incoming request (if available), and starts a new tracing span using - the provided name. If no context is found, a new span is started without context. - Behavior: - - Adds session ID to span attributes if available in kwargs. - - Attempts to extract a tracing context from the 'request' argument or from positional arguments. - - Starts a tracing span with the extracted context (if present) or as a new trace. - - Logs debug information about the tracing context and span creation. - - Supports usage both within and outside of HTTP request contexts. - Example: - @observe("my_function_span") - def my_function(request: Request, ...): - ... - """ def decorator(func): - decorated_func = agent(name)(func) - logger.debug("Decorating function with observe:", name) - + print("Decorating:", func.__name__) @functools.wraps(func) def wrapper(*args, **kwargs): - add_session_id_to_span_attributes(**kwargs) - request: Request = kwargs.get("request") + print(f"[observe] wrapper called for {func.__name__}") + request = kwargs.get("request") + print(f"request: {request}") + print(f"kwargs: {kwargs}") if request is None: for arg in args: if isinstance(arg, Request): request = arg break - # Extract context from request if available - ctx = extract(request.headers) if request else None - - if ctx: - logger.debug(f"Starting span with context: {ctx}") - # Start span with context - with tracer.start_as_current_span(name, context=ctx, kind=SpanKind.SERVER): - return decorated_func(*args, **kwargs) + if request: + print(f"[observe] Request headers: {request.headers}") + ctx = extract(request.headers) if request else None + if ctx: + print(f"[observe] Extracted context: {ctx}") + with tracer.start_as_current_span(name, context=ctx, kind=SpanKind.SERVER): + return func(*args, **kwargs) + else: + print("[observe] No context found in request headers.") else: - logger.debug("No context found, starting span without context.") - - # Start span without context - # This is useful for cases where we want to start a span without any specific context - # e.g., when the function is called outside of an HTTP request context - # or when we want to create a fresh new trace or context is not properly propagated. - return decorated_func(*args, **kwargs) + print("[observe] No request found.") + return func(*args, **kwargs) return wrapper - return decorator class CustomFileSpanExporter(SpanExporter):