From 06ee1ae746dbf0b9d3e1eb9e8342f8faabb790e2 Mon Sep 17 00:00:00 2001 From: corentings Date: Fri, 23 Jan 2026 16:13:00 +0100 Subject: [PATCH 1/2] feat: examples --- basalt/observability/context_managers.py | 25 +- basalt/observability/instrumentation.py | 8 + examples/microservices/.gitignore | 39 +++ examples/microservices/pyproject.toml | 36 +++ examples/microservices/run.sh | 74 +++++ examples/microservices/service_a/__init__.py | 1 + examples/microservices/service_a/main.py | 277 +++++++++++++++++++ examples/microservices/service_b/__init__.py | 1 + examples/microservices/service_b/main.py | 259 +++++++++++++++++ pyproject.toml | 1 - uv.lock | 39 ++- 11 files changed, 736 insertions(+), 24 deletions(-) create mode 100644 examples/microservices/.gitignore create mode 100644 examples/microservices/pyproject.toml create mode 100755 examples/microservices/run.sh create mode 100644 examples/microservices/service_a/__init__.py create mode 100644 examples/microservices/service_a/main.py create mode 100644 examples/microservices/service_b/__init__.py create mode 100644 examples/microservices/service_b/main.py diff --git a/basalt/observability/context_managers.py b/basalt/observability/context_managers.py index 471a499..77f7799 100644 --- a/basalt/observability/context_managers.py +++ b/basalt/observability/context_managers.py @@ -725,13 +725,32 @@ def _with_span_handle( tokens.append(attach(set_value(FEATURE_SLUG_CONTEXT_KEY, feature_slug))) - # If this is a root span (no parent), store it in context - is_root = parent_span is None + # Determine if this should be treated as a Basalt root span + # A span is a Basalt root if: + # 1. There's no parent span at all (true root), OR + # 2. This is a start_observe (span_type="basalt_trace") AND the parent is NOT a Basalt span + # (e.g., parent is from FastAPI, httpx, or other instrumentation) root_span_token = None - # Check if we're inside a basalt trace + # Check if we're already inside a basalt trace in_basalt_trace = otel_context.get_value(ROOT_SPAN_CONTEXT_KEY) is not None + # Determine root status + if parent_span is None: + # No parent at all - this is a true root + is_root = True + elif span_type == "basalt_trace" and not in_basalt_trace: + # Parent exists but it's NOT a Basalt span (e.g., FastAPI HTTP span) + # AND this is a start_observe call (basalt_trace type) + # -> Treat as Basalt root (allows start_observe to work inside FastAPI handlers) + is_root = True + else: + # Parent exists and either: + # - We're already in a Basalt trace, OR + # - This is not a start_observe call + # -> Treat as nested span + is_root = False + # Make trace-level sampling decision should_evaluate_token = None if is_root: diff --git a/basalt/observability/instrumentation.py b/basalt/observability/instrumentation.py index 0555d71..54cf985 100644 --- a/basalt/observability/instrumentation.py +++ b/basalt/observability/instrumentation.py @@ -403,6 +403,14 @@ def _instrument_providers(self, config: TelemetryConfig) -> None: # Try to import the instrumentor instrumentor_cls = _safe_import(module_name, class_name) + if not instrumentor_cls and provider_key == "google_generativeai": + # Fallback: some environments use the GenAI SDK under the same provider name. + instrumentor_cls = _safe_import( + "opentelemetry.instrumentation.google_genai", + "GoogleGenAiSdkInstrumentor", + ) + if instrumentor_cls: + module_name = "opentelemetry.instrumentation.google_genai" if not instrumentor_cls: logger.debug( f"Provider '{provider_key}' instrumentor not available. " diff --git a/examples/microservices/.gitignore b/examples/microservices/.gitignore new file mode 100644 index 0000000..97a20fd --- /dev/null +++ b/examples/microservices/.gitignore @@ -0,0 +1,39 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +env/ +ENV/ + +# Hatch +.hatch/ + +# Environment files - NEVER commit actual secrets! +.envrc +.envrc.local +.env +.env.local + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Testing +.pytest_cache/ +.coverage +htmlcov/ diff --git a/examples/microservices/pyproject.toml b/examples/microservices/pyproject.toml new file mode 100644 index 0000000..2a25439 --- /dev/null +++ b/examples/microservices/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "basalt-microservices-example" +version = "0.1.0" +description = "Microservices example demonstrating Basalt observability across HTTP communication" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.envs.default] +detached = true +dependencies = [ + # Web framework dependencies + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "httpx>=0.25.0", + + # LLM SDKs + "openai==2.15.0", + "google-genai==1.60.0", + + # LLM Auto-instrumentation (FastAPI instrumentation removed due to conflicts with Basalt) + "opentelemetry-instrumentation-openai~=0.51.0", + # "opentelemetry-instrumentation-google-genai~=0.5b0", # NEW Google GenAI SDK (from google import genai) + "opentelemetry-instrumentation-google-generativeai~=0.51.0", + "opentelemetry.instrumentation.fastapi", + # OTLP Exporter for local telemetry collection + "opentelemetry-exporter-otlp-proto-grpc~=1.39.1", +] + +[tool.hatch.envs.default.scripts] +service-a = "uvicorn service_a.main:app --host 0.0.0.0 --port 8001" +service-b = "uvicorn service_b.main:app --host 0.0.0.0 --port 8002" diff --git a/examples/microservices/run.sh b/examples/microservices/run.sh new file mode 100755 index 0000000..78e2457 --- /dev/null +++ b/examples/microservices/run.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if direnv is loaded +if [ -z "$BASALT_API_KEY" ]; then + echo -e "${RED}Error: Environment not loaded.${NC}" + echo "Please run: ${YELLOW}direnv allow${NC}" + echo "Then edit .envrc with your Basalt API key" + exit 1 +fi + +echo -e "${GREEN}Environment loaded successfully${NC}" +echo "BASALT_ENVIRONMENT: $BASALT_ENVIRONMENT" +echo "BASALT_SERVICE_NAME: $BASALT_SERVICE_NAME" +echo "" + +# Change to the microservices directory +cd "$(dirname "$0")" + +# Check if hatch environment exists +if ! hatch env show default &>/dev/null; then + echo -e "${YELLOW}Creating hatch environment...${NC}" + hatch env create +fi + +# Install basalt-py from parent directory +echo -e "${YELLOW}Installing basalt-py from parent directory...${NC}" +hatch run pip install -q -e ../.. + +echo -e "${GREEN}Starting microservices...${NC}" +echo "" + +# Start services in background +echo -e "${GREEN}Starting Service B on port 8002...${NC}" +hatch run service-b & +SERVICE_B_PID=$! + +# Wait a moment for Service B to start +sleep 2 + +echo -e "${GREEN}Starting Service A on port 8001...${NC}" +hatch run service-a & +SERVICE_A_PID=$! + +# Wait for services to fully start +sleep 2 + +# Trap to cleanup on exit +trap "echo -e '\n${YELLOW}Shutting down services...${NC}'; kill $SERVICE_A_PID $SERVICE_B_PID 2>/dev/null; exit 0" SIGINT SIGTERM EXIT + +echo "" +echo -e "${GREEN}✓ Services started successfully!${NC}" +echo "" +echo "Service A: http://localhost:8001" +echo "Service B: http://localhost:8002" +echo "" +echo -e "${YELLOW}Test the microservices:${NC}" +echo " curl http://localhost:8001/process-request" +echo "" +echo -e "${YELLOW}Health checks:${NC}" +echo " curl http://localhost:8001/health" +echo " curl http://localhost:8002/health" +echo "" +echo -e "${RED}Press Ctrl+C to stop all services${NC}" +echo "" + +# Wait for processes +wait diff --git a/examples/microservices/service_a/__init__.py b/examples/microservices/service_a/__init__.py new file mode 100644 index 0000000..fae7038 --- /dev/null +++ b/examples/microservices/service_a/__init__.py @@ -0,0 +1 @@ +"""Service A - Primary service that orchestrates requests to Service B.""" diff --git a/examples/microservices/service_a/main.py b/examples/microservices/service_a/main.py new file mode 100644 index 0000000..81641dc --- /dev/null +++ b/examples/microservices/service_a/main.py @@ -0,0 +1,277 @@ +""" +Service A - Primary Service + +This service demonstrates: +- start_observe with feature_slug "support-ticket" +- HTTP request to Service B +- Distributed tracing across services +- Proper shutdown for telemetry flushing +- Auto-instrumentation for FastAPI and httpx +""" + +import logging +import os +from contextlib import asynccontextmanager + +import httpx +from fastapi import FastAPI, HTTPException +from openai import AsyncOpenAI +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + +from basalt import Basalt, TelemetryConfig +from basalt.observability import Observe, ObserveKind, async_start_observe + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +# Global Basalt client +basalt_client: Basalt | None = None + +# OpenAI client and configuration +openai_client: AsyncOpenAI | None = None +OPENAI_MODEL = "gpt-4o-mini" + +# Service B endpoint +SERVICE_B_URL = os.getenv("SERVICE_B_URL", "http://localhost:8002") + + +def build_basalt_client() -> Basalt: + """ + Initialize the Basalt client with local OTLP exporter. + + Uses custom OTLP exporter to avoid conflicts with FastAPI instrumentation. + Based on pattern from examples/gemini_random_data_example.py. + """ + # Get API key for authentication + basalt_key = os.getenv("BASALT_API_KEY") + if not basalt_key: + logger.warning("BASALT_API_KEY not found. Using placeholder.") + basalt_key = "test-key" + + # Use environment variable for OTLP endpoint or default to localhost + otlp_endpoint = os.getenv("BASALT_OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") + + # Create custom exporter with authentication headers + # Note: insecure=True is used for local/demo purposes + exporter = OTLPSpanExporter( + endpoint=otlp_endpoint, + headers={"authorization": f"Bearer {basalt_key}"}, + insecure=True, + timeout=10, + ) + + # Configure telemetry with OpenAI auto-instrumentation + telemetry = TelemetryConfig( + service_name="service-a-orchestrator", + enabled_providers=["openai"], # Auto-instrument OpenAI SDK calls + trace_content=True, # Capture prompt and completion content + # exporter=[exporter], # Use custom local exporter + ) + + return Basalt(api_key=basalt_key, telemetry_config=telemetry) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage application lifecycle - startup and shutdown.""" + global basalt_client, openai_client + + # Startup + logger.info("Starting Service A...") + basalt_client = build_basalt_client() + logger.info("Basalt client initialized with OpenAI auto-instrumentation") + + # Initialize OpenAI client + openai_api_key = os.getenv("OPENAI_API_KEY") + if openai_api_key: + openai_client = AsyncOpenAI(api_key=openai_api_key) + logger.info("OpenAI client initialized") + else: + logger.warning("OPENAI_API_KEY not set. LLM generation will be disabled.") + + # Auto-instrument httpx for outgoing HTTP calls + HTTPXClientInstrumentor().instrument() + logger.info("HTTPXClientInstrumentor enabled - outgoing HTTP calls will be traced") + + yield + + # Shutdown + logger.info("Shutting down Service A...") + + # Close OpenAI client + if openai_client: + await openai_client.close() + logger.info("OpenAI client closed") + + # Uninstrument httpx + HTTPXClientInstrumentor().uninstrument() + + if basalt_client: + logger.info("Flushing telemetry...") + basalt_client.shutdown() + logger.info("Telemetry flushed successfully") + + +# Create FastAPI app with lifespan management +app = FastAPI(title="Service A - Primary Service", lifespan=lifespan) + +# Auto-instrument FastAPI for incoming HTTP requests (distributed tracing!) +# This now works with Basalt thanks to the smart root detection fix +FastAPIInstrumentor.instrument_app(app) +logger.info("FastAPI instrumentation enabled - distributed tracing active") + + +@Observe(kind=ObserveKind.TOOL, name="call_service_b") +async def call_service_b() -> dict: + """ + Make HTTP request to Service B for analysis. + + This function handles the HTTP communication and error handling. + The httpx client is auto-instrumented, so this call will be traced automatically. + """ + try: + async with httpx.AsyncClient(timeout=30.0) as client: + logger.info(f"Calling Service B at {SERVICE_B_URL}/analyze") + response = await client.get(f"{SERVICE_B_URL}/analyze") + response.raise_for_status() + + result = response.json() + logger.info(f"Received response from Service B: {result.get('status', 'unknown')}") + return result + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error from Service B: {e.response.status_code}") + raise HTTPException(status_code=502, detail=f"Service B returned error: {e.response.status_code}") + + except httpx.RequestError as e: + logger.error(f"Request error calling Service B: {e}") + raise HTTPException(status_code=503, detail="Service B is unavailable") + + except Exception as e: + logger.error(f"Unexpected error calling Service B: {e}") + raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") + + +async def summarize_with_llm(service_b_response: dict) -> dict: + """ + Use OpenAI to generate a summary of Service B's analysis. + + This function demonstrates auto-instrumented OpenAI calls: + - GENERATION spans created automatically + - Token usage captured automatically + - Model name and content tracked + - Integrated with Basalt prompts API + """ + if not openai_client: + logger.warning("OpenAI client not available") + return {"summary": "LLM unavailable", "error": "API key not set"} + + try: + # Get prompt from Basalt API + prompt_cm = await basalt_client.prompts.get( + slug="joke-analyzer", + variables={ + "analysis": str(service_b_response.get("analysis", {})), + "ticket_id": service_b_response.get("ticket_id", "unknown") + } + ) + + async with prompt_cm as prompt: + logger.info(f"Retrieved prompt: {prompt.slug} v{prompt.version}") + + # Auto-instrumented OpenAI call - GENERATION span created automatically + response = await openai_client.chat.completions.create( + model=OPENAI_MODEL, + messages=[ + {"role": "system", "content": "You are a helpful assistant that summarizes support ticket analyses."}, + {"role": "user", "content": f"Summarize this analysis in 2-3 sentences: {service_b_response}"} + ], + temperature=0.7, + max_tokens=150 + ) + + return { + "summary": response.choices[0].message.content, + "model": OPENAI_MODEL, + "tokens_used": response.usage.total_tokens, + "prompt_slug": prompt.slug, + "prompt_version": prompt.version + } + + except Exception as e: + logger.error(f"Error generating LLM summary: {e}") + return {"summary": "Error", "error": str(e)} + + +@app.get("/process-request") +async def process_support_request(): + """ + Main endpoint for processing support requests. + + This demonstrates: + 1. start_observe with feature_slug="support-ticket" + 2. HTTP call to Service B (auto-instrumented via httpx) + 3. Distributed tracing (trace context propagated via HTTP headers) + 4. Proper input/output tracking + """ + async with async_start_observe( + name="process_support_request", feature_slug="support-ticket" + ) as root_span: + # Set input for observability + request_data = {"request_type": "support_ticket_processing", "source": "service-a"} + root_span.set_input(request_data) + root_span.set_metadata({"service": "service-a", "endpoint": "/process-request"}) + + logger.info("Processing support request") + + # Call Service B for analysis + # Note: httpx automatically propagates OpenTelemetry context via HTTP headers + # This enables distributed tracing across services + analysis_result = await call_service_b() + + # Generate LLM summary using OpenAI + llm_summary = await summarize_with_llm(analysis_result) + + # Prepare response + response = { + "status": "completed", + "request_type": request_data["request_type"], + "service_b_response": analysis_result, + "llm_summary": llm_summary, # OpenAI-generated summary + "processed_by": "service-a", + } + + # Set output for observability + root_span.set_output(response) + + logger.info("Support request processing completed") + + return response + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "service-a"} + + +@app.get("/") +async def root(): + """Root endpoint with usage instructions.""" + return { + "service": "Service A - Primary Service", + "endpoints": { + "/process-request": "Process a support request (calls Service B)", + "/health": "Health check", + }, + "instructions": "Try: curl http://localhost:8001/process-request", + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/examples/microservices/service_b/__init__.py b/examples/microservices/service_b/__init__.py new file mode 100644 index 0000000..3623a6c --- /dev/null +++ b/examples/microservices/service_b/__init__.py @@ -0,0 +1 @@ +"""Service B - Analysis service with retrieval and prompt integration.""" diff --git a/examples/microservices/service_b/main.py b/examples/microservices/service_b/main.py new file mode 100644 index 0000000..bc9720c --- /dev/null +++ b/examples/microservices/service_b/main.py @@ -0,0 +1,259 @@ +""" +Service B - Analysis Service + +This service demonstrates: +- start_observe with feature_slug "support-ticket" +- Nested observe span with kind=RETRIEVAL +- Prompt retrieval using prompts API (joke-analyzer) +- Proper shutdown for telemetry flushing +- Auto-instrumentation for FastAPI +""" + +import logging +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from google import genai +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + +from basalt import Basalt, TelemetryConfig +from basalt.observability import ObserveKind, async_observe, async_start_observe + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +# Global Basalt client +basalt_client: Basalt | None = None + +# Gemini model configuration +GEMINI_MODEL = "gemini-2.5-flash-lite" + + +def build_basalt_client() -> Basalt: + """ + Initialize the Basalt client with local OTLP exporter and Gemini instrumentation. + + Uses custom OTLP exporter to avoid conflicts with FastAPI instrumentation. + Based on pattern from examples/gemini_random_data_example.py. + """ + # Get API key for authentication + basalt_key = os.getenv("BASALT_API_KEY") + if not basalt_key: + logger.warning("BASALT_API_KEY not found. Using placeholder.") + basalt_key = "test-key" + + # Use environment variable for OTLP endpoint or default to localhost + otlp_endpoint = os.getenv("BASALT_OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") + + # Create custom exporter with authentication headers + exporter = OTLPSpanExporter( + endpoint=otlp_endpoint, + headers={"authorization": f"Bearer {basalt_key}"}, + insecure=True, + timeout=10, + ) + + # Configure telemetry with Gemini auto-instrumentation + telemetry = TelemetryConfig( + service_name="service-b-analysis", + enabled_providers=["google_generativeai"], # NEW Google GenAI SDK (from google import genai) + trace_content=True, # Capture prompt and completion content + # exporter=[exporter], # Use custom local exporter + ) + + return Basalt(api_key=basalt_key, telemetry_config=telemetry) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage application lifecycle - startup and shutdown.""" + global basalt_client + + # Startup + logger.info("Starting Service B...") + basalt_client = build_basalt_client() + logger.info("Basalt client initialized for production") + + yield + + # Shutdown + logger.info("Shutting down Service B...") + if basalt_client: + logger.info("Flushing telemetry...") + basalt_client.shutdown() + logger.info("Telemetry flushed successfully") + + +# Create FastAPI app with lifespan management +app = FastAPI(title="Service B - Analysis Service", lifespan=lifespan) + +# Auto-instrument FastAPI for incoming HTTP requests (distributed tracing!) +# This now works with Basalt thanks to the smart root detection fix +FastAPIInstrumentor.instrument_app(app) +logger.info("FastAPI instrumentation enabled - distributed tracing active") + + +async def perform_retrieval(query: str) -> dict: + """ + Simulate a vector database retrieval operation. + + This function demonstrates the RETRIEVAL observe kind with proper + span configuration including query, results_count, and top_k. + """ + async with async_observe(kind=ObserveKind.RETRIEVAL, name="retrieve_ticket_context_in_b") as span: + # Set retrieval query + span.set_input(query) + + # Simulate retrieval from vector database + # In a real application, this would query a vector DB like Pinecone, Qdrant, etc. + simulated_results = [ + {"id": "doc-1", "content": "Previous support ticket about billing", "score": 0.95}, + {"id": "doc-2", "content": "FAQ about account management", "score": 0.87}, + {"id": "doc-3", "content": "Knowledge base article on troubleshooting", "score": 0.82}, + ] + + # Set retrieval metadata + span.set_top_k(5) + span.set_metadata({"retrieval_type": "vector_search", "index": "support-tickets"}) + + logger.info(f"Retrieved {len(simulated_results)} results for query: {query}") + + return {"query": query, "results": simulated_results, "count": len(simulated_results)} + + +async def analyze_with_prompt(ticket_data: dict, context: dict) -> dict: + """ + Analyze ticket using Gemini LLM with the joke-analyzer prompt. + + This function demonstrates auto-instrumented Gemini calls: + - GENERATION spans created automatically + - Token usage captured automatically + - Model name and content tracked + - Integrated with Basalt prompts API + """ + gemini_api_key = os.getenv("GEMINI_API_KEY") + if not gemini_api_key: + logger.warning("GEMINI_API_KEY not set. Using fallback.") + return { + "analysis": "Fallback analysis (API key not set)", + "error": "GEMINI_API_KEY not configured" + } + + try: + # Retrieve prompt from Basalt + prompt_cm = await basalt_client.prompts.get( + slug="joke-analyzer", + variables={ + "ticket_id": ticket_data.get("ticket_id", "unknown"), + "context_count": context["count"] + } + ) + + async with prompt_cm as prompt: + logger.info(f"Retrieved prompt: {prompt.slug} v{prompt.version}") + + # Auto-instrumented Gemini call - GENERATION span created automatically + client = genai.Client(api_key=gemini_api_key) + async with client.aio as aclient: + response = await aclient.models.generate_content( + model=GEMINI_MODEL, + contents=f"""Analyze this support ticket: + +Ticket ID: {ticket_data.get("ticket_id", "unknown")} +Context retrieved: {context["count"]} documents + +Provide: +1. Sentiment analysis +2. Priority level (low/medium/high) +3. Brief analysis (2-3 sentences) + +Context: {context.get("results", [])} +""" + ) + + # Extract response and metadata + analysis_text = response.text + + # Get token usage if available + usage_metadata = getattr(response, "usage_metadata", None) + tokens_used = None + if usage_metadata: + tokens_used = { + "prompt_tokens": getattr(usage_metadata, "prompt_token_count", 0), + "completion_tokens": getattr(usage_metadata, "candidates_token_count", 0), + "total_tokens": getattr(usage_metadata, "total_token_count", 0) + } + + return { + "prompt_slug": prompt.slug, + "prompt_version": prompt.version, + "analysis": analysis_text, + "model": GEMINI_MODEL, + "tokens_used": tokens_used, + "context_used": context["count"], + } + + except Exception as e: + logger.error(f"Error with Gemini analysis: {e}") + return { + "analysis": "Fallback analysis (Gemini failed)", + "error": str(e), + } + + +@app.get("/analyze") +async def analyze_ticket(): + """ + Main endpoint for ticket analysis. + + This demonstrates: + 1. start_observe with feature_slug="support-ticket" + 2. Nested observe span with kind=RETRIEVAL + 3. Prompt retrieval using the prompts API + 4. Proper input/output tracking + """ + async with async_start_observe( + name="analyze_support_ticket", feature_slug="support-ticket" + ) as root_span: + # Set input for observability + ticket_data = {"ticket_id": "DEMO-001", "request_type": "analysis"} + root_span.set_input(ticket_data) + root_span.set_metadata({"service": "service-b", "endpoint": "/analyze"}) + + logger.info(f"Analyzing ticket: {ticket_data['ticket_id']}") + + # Step 1: Perform retrieval + retrieval_context = await perform_retrieval("support ticket analysis context") + + # Step 2: Analyze using prompt + analysis = await analyze_with_prompt(ticket_data, retrieval_context) + + # Prepare response + response = { + "status": "success", + "ticket_id": ticket_data["ticket_id"], + "analysis": analysis, + "retrieval_context_count": retrieval_context["count"], + } + + # Set output for observability + root_span.set_output(response) + + logger.info(f"Analysis completed for ticket: {ticket_data['ticket_id']}") + + return response + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "service-b"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8002) diff --git a/pyproject.toml b/pyproject.toml index 9d7998f..213d5a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,6 @@ anthropic = [ google-generativeai = [ "opentelemetry-instrumentation-google-generativeai~=0.51.0", ] -# Note: google-genai instrumentation not yet available in openllmetry # google-genai = [ # "opentelemetry-instrumentation-google-genai~=0.5b0", # ] diff --git a/uv.lock b/uv.lock index dd0ff80..66857e4 100644 --- a/uv.lock +++ b/uv.lock @@ -82,6 +82,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-sdk" }, { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, { name = "wrapt" }, ] @@ -233,6 +234,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'" }, { name = "setuptools", marker = "extra == 'dev'" }, { name = "twine", marker = "extra == 'dev'" }, + { name = "typing-extensions", specifier = ">=4.10.0" }, { name = "wheel", marker = "extra == 'dev'" }, { name = "wrapt", specifier = "~=1.17.3" }, ] @@ -282,15 +284,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, ] -[[package]] -name = "cachetools" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, -] - [[package]] name = "certifi" version = "2025.10.5" @@ -666,35 +659,41 @@ wheels = [ [[package]] name = "google-auth" -version = "2.41.1" +version = "2.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachetools" }, { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/af/5129ce5b2f9688d2fa49b463e544972a7c82b0fdb50980dafee92e121d9f/google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2", size = 292284, upload-time = "2025-09-30T22:51:26.363Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/3c/ec64b9a275ca22fa1cd3b6e77fefcf837b0732c890aa32d2bd21313d9b33/google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da", size = 323719, upload-time = "2026-01-06T21:55:31.045Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/79e9008530b79527e0d5f79e7eef08d3b179b7f851cfd3a2f27822fbdfa9/google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498", size = 234867, upload-time = "2026-01-06T21:55:28.6Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, ] [[package]] name = "google-genai" -version = "1.50.1" +version = "1.60.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "google-auth" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, { name = "httpx" }, { name = "pydantic" }, { name = "requests" }, + { name = "sniffio" }, { name = "tenacity" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/74/1382f655a8c24adc2811f113018ff2b3884f333284ba9bff5c57f8dbcbba/google_genai-1.50.1.tar.gz", hash = "sha256:8f0d95b1b165df71e6a7e1c0d0cadb5fad30f913f42c6b131b9ebb504eec0e5f", size = 254693, upload-time = "2025-11-13T23:17:22.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/3f/a753be0dcee352b7d63bc6d1ba14a72591d63b6391dac0cdff7ac168c530/google_genai-1.60.0.tar.gz", hash = "sha256:9768061775fddfaecfefb0d6d7a6cabefb3952ebd246cd5f65247151c07d33d1", size = 487721, upload-time = "2026-01-21T22:17:30.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/6b/78a7588d9a4f6c8c8ed326a32385d0566a3262c91c3f7a005e4231207894/google_genai-1.50.1-py3-none-any.whl", hash = "sha256:15ae694b080269c53d325dcce94622f33e94cf81bd2123f029ab77e6b8f09eab", size = 257324, upload-time = "2025-11-13T23:17:21.259Z" }, + { url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" }, ] [[package]] @@ -1266,7 +1265,7 @@ wheels = [ [[package]] name = "openai" -version = "2.8.0" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1278,9 +1277,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0c/b9321e12f89e236f5e9a46346c30fb801818e22ba33b798a5aca84be895c/openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad", size = 602412, upload-time = "2025-11-13T18:15:25.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/e1/0a6560bab7fb7b5a88d35a505b859c6d969cb2fa2681b568eb5d95019dec/openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf", size = 1022692, upload-time = "2025-11-13T18:15:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, ] [[package]] From 855544abd74c4223c83b22ef01fc963adcf25d68 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:40:41 +0100 Subject: [PATCH 2/2] Update examples/microservices/pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/microservices/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/microservices/pyproject.toml b/examples/microservices/pyproject.toml index 2a25439..e6f9a0f 100644 --- a/examples/microservices/pyproject.toml +++ b/examples/microservices/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "opentelemetry-instrumentation-openai~=0.51.0", # "opentelemetry-instrumentation-google-genai~=0.5b0", # NEW Google GenAI SDK (from google import genai) "opentelemetry-instrumentation-google-generativeai~=0.51.0", - "opentelemetry.instrumentation.fastapi", + "opentelemetry-instrumentation-fastapi", # OTLP Exporter for local telemetry collection "opentelemetry-exporter-otlp-proto-grpc~=1.39.1", ]