diff --git a/examples/dataset/.gitignore b/examples/dataset/.gitignore new file mode 100644 index 0000000..97a20fd --- /dev/null +++ b/examples/dataset/.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/dataset_api_example.py b/examples/dataset/dataset_api_example.py similarity index 99% rename from examples/dataset_api_example.py rename to examples/dataset/dataset_api_example.py index a0d4f5e..964f893 100644 --- a/examples/dataset_api_example.py +++ b/examples/dataset/dataset_api_example.py @@ -27,8 +27,8 @@ UnauthorizedError, ) -# Add parent directory to path to import basalt -project_root = Path(__file__).parent.parent +# Add repo root to path to import basalt +project_root = Path(__file__).resolve().parents[2] sys.path.insert(0, str(project_root)) os.environ["BASALT_BUILD"] = "development" diff --git a/examples/dataset_sdk_demo.ipynb b/examples/dataset/dataset_sdk_demo.ipynb similarity index 100% rename from examples/dataset_sdk_demo.ipynb rename to examples/dataset/dataset_sdk_demo.ipynb diff --git a/examples/dataset/pyproject.toml b/examples/dataset/pyproject.toml new file mode 100644 index 0000000..d5d16fa --- /dev/null +++ b/examples/dataset/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "basalt-dataset-example" +version = "0.1.0" +description = "Dataset API examples for Basalt" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.envs.default] +detached = true +dependencies = [ + "httpx>=0.25.0", +] + +[tool.hatch.envs.default.scripts] +run = "python dataset_api_example.py" diff --git a/examples/dataset/run.sh b/examples/dataset/run.sh new file mode 100755 index 0000000..b4479da --- /dev/null +++ b/examples/dataset/run.sh @@ -0,0 +1,39 @@ +#!/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 script 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}Running dataset example...${NC}" +echo "" + +hatch run run diff --git a/examples/gemini_simple/.gitignore b/examples/gemini_simple/.gitignore new file mode 100644 index 0000000..97a20fd --- /dev/null +++ b/examples/gemini_simple/.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/gemini_random_data_example.py b/examples/gemini_simple/gemini_random_data_example.py similarity index 100% rename from examples/gemini_random_data_example.py rename to examples/gemini_simple/gemini_random_data_example.py diff --git a/examples/gemini_simple/pyproject.toml b/examples/gemini_simple/pyproject.toml new file mode 100644 index 0000000..c708554 --- /dev/null +++ b/examples/gemini_simple/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "basalt-gemini-simple-example" +version = "0.1.0" +description = "Simple Gemini example demonstrating Basalt observability" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.envs.default] +detached = true +dependencies = [ + "google-genai==1.60.0", + "httpx>=0.25.0", + "opentelemetry-exporter-otlp-proto-grpc~=1.39.1", +] + +[tool.hatch.envs.default.scripts] +run = "python gemini_random_data_example.py" diff --git a/examples/gemini_simple/run.sh b/examples/gemini_simple/run.sh new file mode 100755 index 0000000..b41d2c4 --- /dev/null +++ b/examples/gemini_simple/run.sh @@ -0,0 +1,39 @@ +#!/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 script 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}Running Gemini example...${NC}" +echo "" + +hatch run run diff --git a/examples/multi_exporter/.gitignore b/examples/multi_exporter/.gitignore new file mode 100644 index 0000000..97a20fd --- /dev/null +++ b/examples/multi_exporter/.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/multi_exporter_example.py b/examples/multi_exporter/multi_exporter_example.py similarity index 100% rename from examples/multi_exporter_example.py rename to examples/multi_exporter/multi_exporter_example.py diff --git a/examples/multi_exporter/pyproject.toml b/examples/multi_exporter/pyproject.toml new file mode 100644 index 0000000..a115cd9 --- /dev/null +++ b/examples/multi_exporter/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "basalt-multi-exporter-example" +version = "0.1.0" +description = "Multi-exporter example demonstrating Basalt observability" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.envs.default] +detached = true +dependencies = [ + "opentelemetry-exporter-otlp-proto-grpc~=1.39.1", + "opentelemetry-sdk~=1.39.1", +] + +[tool.hatch.envs.default.scripts] +run = "python multi_exporter_example.py" diff --git a/examples/multi_exporter/run.sh b/examples/multi_exporter/run.sh new file mode 100755 index 0000000..17b3db9 --- /dev/null +++ b/examples/multi_exporter/run.sh @@ -0,0 +1,39 @@ +#!/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 script 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}Running multi-exporter example...${NC}" +echo "" + +hatch run run diff --git a/examples/openai_simple/.gitignore b/examples/openai_simple/.gitignore new file mode 100644 index 0000000..97a20fd --- /dev/null +++ b/examples/openai_simple/.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/openai_example.py b/examples/openai_simple/openai_example.py similarity index 100% rename from examples/openai_example.py rename to examples/openai_simple/openai_example.py diff --git a/examples/openai_simple/pyproject.toml b/examples/openai_simple/pyproject.toml new file mode 100644 index 0000000..e1429a9 --- /dev/null +++ b/examples/openai_simple/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "basalt-openai-simple-example" +version = "0.1.0" +description = "Simple OpenAI example demonstrating Basalt observability" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.envs.default] +detached = true +dependencies = [ + "openai==2.15.0", + "opentelemetry-exporter-otlp-proto-grpc~=1.39.1", +] + +[tool.hatch.envs.default.scripts] +run = "python openai_example.py" diff --git a/examples/openai_simple/run.sh b/examples/openai_simple/run.sh new file mode 100755 index 0000000..b0f7feb --- /dev/null +++ b/examples/openai_simple/run.sh @@ -0,0 +1,39 @@ +#!/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 script 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}Running OpenAI example...${NC}" +echo "" + +hatch run run diff --git a/examples/reproduce_trace_bug/.gitignore b/examples/reproduce_trace_bug/.gitignore new file mode 100644 index 0000000..97a20fd --- /dev/null +++ b/examples/reproduce_trace_bug/.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/reproduce_trace_bug/pyproject.toml b/examples/reproduce_trace_bug/pyproject.toml new file mode 100644 index 0000000..e22d90d --- /dev/null +++ b/examples/reproduce_trace_bug/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "basalt-trace-bug-microservices" +version = "0.1.0" +description = "Repro microservices example for trace context propagation" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.envs.default] +detached = true +dependencies = [ + "fastapi>=0.115.0", + "uvicorn>=0.32.0", + "httpx>=0.27.0", + "python-dotenv>=1.0.0", + "google-genai==1.60.0", + "opentelemetry-api>=1.39.1", + "opentelemetry-sdk>=1.39.1", + "opentelemetry-instrumentation-httpx>=0.60b1", + "opentelemetry-instrumentation-fastapi>=0.59b0", + "opentelemetry.instrumentation.google_generativeai>=0.51.0", +] + +[tool.hatch.envs.default.scripts] +service-a = "uvicorn service_a:app --host 0.0.0.0 --port 8000" +service-b = "uvicorn service_b:app --host 0.0.0.0 --port 8001" diff --git a/examples/reproduce_trace_bug/run.sh b/examples/reproduce_trace_bug/run.sh new file mode 100755 index 0000000..6449a34 --- /dev/null +++ b/examples/reproduce_trace_bug/run.sh @@ -0,0 +1,70 @@ +#!/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 service 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 services...${NC}" +echo "" + +# Start services in background +echo -e "${GREEN}Starting Service B on port 8001...${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 8000...${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:8000" +echo "Service B: http://localhost:8001" +echo "" +echo -e "${YELLOW}Test the repro:${NC}" +echo " curl -X POST http://localhost:8000/call-service-b" +echo "" +echo -e "${RED}Press Ctrl+C to stop all services${NC}" +echo "" + +# Wait for processes +wait diff --git a/examples/reproduce_trace_bug/service_a.py b/examples/reproduce_trace_bug/service_a.py new file mode 100644 index 0000000..d437bde --- /dev/null +++ b/examples/reproduce_trace_bug/service_a.py @@ -0,0 +1,95 @@ +""" +Service A (Caller) - Port 8000 +Makes HTTP call to Service B with trace context propagation. +""" + +import logging +import os +from contextlib import asynccontextmanager + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI +from google import genai +from opentelemetry import trace +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + +from basalt import Basalt, TelemetryConfig +from basalt.observability import start_observe + +load_dotenv() +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +BASALT_API_KEY = os.getenv("BASALT_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +def build_basalt_client() -> Basalt: + telemetry_config = TelemetryConfig(service_name="service-a", trace_content=True,enabled_providers=["google_generativeai"]) + basalt_client = Basalt(api_key=BASALT_API_KEY, telemetry_config=telemetry_config) + return basalt_client + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.basalt_client = build_basalt_client() + + logger.info("Service A started") + yield + logger.info("Service A shutting down") + + +app = FastAPI(lifespan=lifespan) +HTTPXClientInstrumentor().instrument() +FastAPIInstrumentor.instrument_app(app) + + +@app.post("/call-service-b") +@start_observe( + feature_slug="new-test", + name="service-a-endpoint", + metadata={"service": "service-a"}, +) +async def call_service_b(): + """Call Service B and check if trace context is propagated.""" + current_span = trace.get_current_span() + span_ctx = current_span.get_span_context() + + logger.info( + "Service A - Before HTTP call: trace_id=%s, span_id=%s, is_valid=%s", + format(span_ctx.trace_id, "032x"), + format(span_ctx.span_id, "016x"), + span_ctx.is_valid, + ) + + # Simple Gemini call in Service A + client = genai.Client(api_key=GEMINI_API_KEY) + gemini_response = client.models.generate_content( + model="gemini-2.0-flash-lite", + contents="Say 'Hello from Service A' in exactly 5 words.", + ) + gemini_text_a = gemini_response.text.strip() if gemini_response.text else None + logger.info("Service A - Gemini response: %s", gemini_text_a) + + async with httpx.AsyncClient() as client: + response = await client.post("http://localhost:8001/endpoint") + + sent_traceparent = response.request.headers.get("traceparent") + logger.info( + "Service A - After HTTP call: sent traceparent=%s", + sent_traceparent, + ) + + return { + "service_a_trace_id": format(span_ctx.trace_id, "032x"), + "service_a_gemini": gemini_text_a, + "sent_traceparent": sent_traceparent, + "service_b_response": response.json(), + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/reproduce_trace_bug/service_b.py b/examples/reproduce_trace_bug/service_b.py new file mode 100644 index 0000000..4378172 --- /dev/null +++ b/examples/reproduce_trace_bug/service_b.py @@ -0,0 +1,114 @@ +""" +Service B (Callee) - Port 8001 +Receives HTTP call from Service A. +Demonstrates that @start_observe creates a NEW trace instead of continuing the parent. +""" + +import logging +import os +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +from fastapi import FastAPI, Request +from google import genai +from opentelemetry import trace +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + +from basalt import Basalt +from basalt.observability import start_observe + +load_dotenv() +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +BASALT_API_KEY = os.getenv("BASALT_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +def build_basalt_client()-> Basalt: + basalt_client = Basalt(api_key=BASALT_API_KEY) + return basalt_client + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.basalt_client = build_basalt_client() + logger.info("Service B started") + yield + logger.info("Service B shutting down") + + +app = FastAPI(lifespan=lifespan) +FastAPIInstrumentor.instrument_app(app) + + +@app.post("/endpoint") +@start_observe( + feature_slug="new-test", + name="service-b-endpoint", + metadata={"service": "service-b"}, +) +async def endpoint(request: Request): + """ + Endpoint that receives the call from Service A. + + BUG: Even though traceparent header is received, @start_observe creates + a NEW trace instead of continuing the parent trace. + """ + # Log incoming headers + traceparent = request.headers.get("traceparent") + tracestate = request.headers.get("tracestate") + logger.info( + "Service B - Incoming headers: traceparent=%s, tracestate=%s", + traceparent, + tracestate, + ) + + # Log current OTel context (set by @start_observe) + current_span = trace.get_current_span() + span_ctx = current_span.get_span_context() + logger.info( + "Service B - Current OTel context: trace_id=%s, span_id=%s, is_valid=%s", + format(span_ctx.trace_id, "032x"), + format(span_ctx.span_id, "016x"), + span_ctx.is_valid, + ) + + # Parse the incoming traceparent to compare + incoming_trace_id = None + if traceparent: + parts = traceparent.split("-") + if len(parts) >= 2: + incoming_trace_id = parts[1] + + current_trace_id = format(span_ctx.trace_id, "032x") + traces_match = incoming_trace_id == current_trace_id + + logger.info( + "Service B - Trace comparison: incoming=%s, current=%s, MATCH=%s", + incoming_trace_id, + current_trace_id, + traces_match, + ) + + # Simple Gemini call in Service B + client = genai.Client(api_key=GEMINI_API_KEY) + gemini_response = client.models.generate_content( + model="gemini-2.0-flash-lite", + contents="Say 'Hello from Service B' in exactly 5 words.", + ) + gemini_text_b = gemini_response.text.strip() if gemini_response.text else None + logger.info("Service B - Gemini response: %s", gemini_text_b) + + return { + "incoming_traceparent": traceparent, + "service_b_trace_id": current_trace_id, + "service_b_gemini": gemini_text_b, + "traces_match": traces_match, + "bug_reproduced": not traces_match, + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8001)