diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/Dockerfile b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/Dockerfile new file mode 100644 index 0000000000..338d654d38 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/Dockerfile @@ -0,0 +1,36 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM python:3.14-slim + +WORKDIR /app + +# Copy the project definition first so pip can install dependencies. +# Layering this before the source copy lets Docker cache the dependency +# layer when only application code changes. +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +# Copy application source and configuration after dependencies are installed. +COPY otel-config.yaml . +COPY app/ app/ +COPY library/ library/ + +EXPOSE 8080 + +# Key environment variables (override with -e / --env): +# OTEL_EXPORTER_OTLP_ENDPOINT — OTLP HTTP endpoint (default: http://localhost:4318) +# OTEL_RESOURCE_ATTRIBUTES — extra resource attributes, e.g. deployment.environment=prod +# APPLICATION_PORT — listening port (default: 8080) +CMD ["python", "-m", "app.main"] diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/README.rst b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/README.rst new file mode 100644 index 0000000000..c685899307 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/README.rst @@ -0,0 +1,160 @@ +Rolldice — OpenTelemetry Reference Application (FastAPI) +========================================================= + +This is the Python/FastAPI implementation of the +`OpenTelemetry reference application specification `_. + +It demonstrates all four OpenTelemetry signal types in a single service: + +* **Traces** — automatic HTTP spans via ``FastAPIInstrumentor``, plus manual + child spans for the dice-rolling business logic. +* **Metrics** — a call counter, an outcome histogram, and an observable gauge, + defined in the library using only the OTel API. +* **Logs** — structured log records emitted through Python's ``logging`` module + and forwarded to OpenTelemetry via ``LoggingHandler`` (the log bridge). +* **Resources** — process and environment metadata attached automatically via + resource detectors. + +Architecture +------------ + +The code is split into two modules to illustrate the recommended library/application boundary: + +``library/rolldice.py`` + Dice logic. Imports only from ``opentelemetry`` (the API package) — **no** + SDK imports. This makes the library usable in any application regardless + of which SDK implementation is chosen. + +``otel-config.yaml`` + Declarative configuration file. Defines the trace, metric, and log + pipelines (exporters, processors, samplers, resource detectors) in a YAML + file following the `OpenTelemetry Configuration Schema + `_. This + replaces programmatic SDK initialization and allows operators to change + telemetry behavior without modifying code. + +``app/telemetry.py`` + SDK initialization. Loads ``otel-config.yaml`` using the SDK's file + configuration API and installs the configured providers globally. Also + sets up the Python logging bridge (``LoggingHandler``), which is not + covered by the config schema. + +``app/main.py`` + FastAPI application. Imports ``app.telemetry`` first, then creates the + ``FastAPI`` app, instruments it, and defines the ``/rolldice`` endpoint. + +Prerequisites +------------- + +* Python ≥ 3.12 +* ``pip`` or ``uv`` + +Install +------- + +.. code-block:: bash + + cd instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice + pip install . + +Run +--- + +.. code-block:: bash + + uvicorn app.main:app --host 0.0.0.0 --port 8080 + +Or directly: + +.. code-block:: bash + + python -m app.main + +The server listens on port 8080 by default. Set ``APPLICATION_PORT`` to use a +different port. + +Test the API +------------ + +.. code-block:: bash + + # Single roll (default) — returns a number, e.g. "4" + curl "http://localhost:8080/rolldice" + + # Five rolls — returns a JSON array, e.g. [3, 5, 2, 6, 1] + curl "http://localhost:8080/rolldice?rolls=5" + + # With a player name (shown in DEBUG logs) + curl "http://localhost:8080/rolldice?rolls=3&player=Alice" + + # Invalid input — returns HTTP 400 with error JSON + curl "http://localhost:8080/rolldice?rolls=abc" + + # Non-positive rolls — returns HTTP 500 with empty body + curl "http://localhost:8080/rolldice?rolls=-1" + +Telemetry output +~~~~~~~~~~~~~~~~ + +Span and metric data is printed to stdout (via the console exporters) even +without a running collector, so you can verify instrumentation locally. + +Configuration +------------- + +Telemetry behavior is defined in ``otel-config.yaml``. You can edit this file +to add or remove exporters, change sampling strategies, or adjust processor +settings — no code changes required. + +Environment variables +--------------------- + ++-----------------------------------+----------------------------------------------+-------------------------+ +| Variable | Description | Default | ++===================================+==============================================+=========================+ +| ``OTEL_EXPORTER_OTLP_ENDPOINT`` | OTLP HTTP endpoint for the backend | ``http://localhost:4318``| ++-----------------------------------+----------------------------------------------+-------------------------+ +| ``OTEL_RESOURCE_ATTRIBUTES`` | Extra resource attributes (``key=value,...``)| *(none)* | ++-----------------------------------+----------------------------------------------+-------------------------+ +| ``APPLICATION_PORT`` | Listening port | ``8080`` | ++-----------------------------------+----------------------------------------------+-------------------------+ + +.. note:: + + ``OTEL_SERVICE_NAME`` is set to ``rolldice`` in ``otel-config.yaml``. To + override it, edit the config file or add ``service.name`` to + ``OTEL_RESOURCE_ATTRIBUTES``. + +Run with an OTLP backend +------------------------ + +Start the `OpenTelemetry Collector `_ or +another OTLP-compatible backend, then: + +.. code-block:: bash + + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ + uvicorn app.main:app --host 0.0.0.0 --port 8080 + +Docker +------ + +Build: + +.. code-block:: bash + + docker build -t rolldice . + +Run (without a collector — console output only): + +.. code-block:: bash + + docker run -p 8080:8080 rolldice + +Run (with a collector on the host): + +.. code-block:: bash + + docker run -p 8080:8080 \ + -e OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:4318 \ + rolldice diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/__init__.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/main.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/main.py new file mode 100644 index 0000000000..b0b3bd5e22 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/main.py @@ -0,0 +1,133 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastAPI application entry point for the rolldice reference application. + +Start-up order matters for OpenTelemetry: + 1. ``app.telemetry`` must be imported first — it installs the SDK providers + globally before anything else creates tracers or meters. + 2. FastAPIInstrumentor must be called after the app is created but still at + import time (not inside a request handler) so it can patch the ASGI + middleware correctly. + 3. Library objects (tracer, meter, metric instruments) are created at module + import time in ``library.rolldice``; because telemetry is already + configured by then, those calls immediately resolve to real SDK objects. +""" + +import logging +import os + +import uvicorn +from fastapi import FastAPI, Query, Response +from fastapi.responses import JSONResponse, PlainTextResponse + +# Importing telemetry first ensures the SDK is initialized before the +# FastAPIInstrumentor (below) registers its ASGI middleware, and before +# library/rolldice.py creates its tracer and meter at module load time. +import app.telemetry # noqa: F401 + +from library import rolldice + +# FastAPIInstrumentor automatically wraps every FastAPI route with an HTTP +# server span. It reads the ASGI scope to populate standard HTTP semantic +# convention attributes such as: +# • http.request.method (GET, POST, …) +# • http.response.status_code +# • url.path +# • server.address / server.port +# It also propagates the W3C TraceContext from incoming request headers so +# that distributed traces connect across service boundaries. +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Rolldice", + description="OpenTelemetry reference application — rolls a six-sided die.", + version="0.1.0", +) + +# instrument_app() wraps the FastAPI ASGI app with OTel middleware. This must +# be called after the app object is created and before the first request is +# served. The instrumentation is additive: manual spans created inside route +# handlers automatically become children of the HTTP span created here. +FastAPIInstrumentor.instrument_app(app) + + +@app.get("/rolldice") +@app.post("/rolldice") +async def rolldice_endpoint( + rolls: str | None = Query(default=None), + player: str | None = Query(default=None), +) -> Response: + """Roll a six-sided die one or more times. + + Query parameters: + - ``rolls``: number of dice to roll (default: 1, must be a positive integer) + - ``player``: optional player name included in debug log output + + Returns a single number (1-6) when rolls is 1, or a JSON array of numbers + when rolls > 1. + """ + # Default to 1 roll when the parameter is omitted. + rolls_raw = rolls if rolls is not None else "1" + + # Validate that the value is numeric before passing it to the library. + # Non-numeric input is a client error (HTTP 400). + try: + rolls_int = int(rolls_raw) + except ValueError: + logger.warning( + "HTTP 400: invalid rolls parameter %r (must be an integer)", + rolls_raw, + ) + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "Parameter rolls must be a positive integer", + }, + ) + + # Delegate dice logic to the library module. The library validates that + # rolls > 0 and raises ValueError for non-positive values, which we map + # to HTTP 500 per the reference application specification. + try: + results = rolldice.roll_dice(rolls_int, player) + except ValueError as exc: + logger.error( + "HTTP 500: library raised ValueError for rolls=%d — %s", + rolls_int, + exc, + ) + return PlainTextResponse(status_code=500, content="") + + logger.info( + "HTTP 200: %s rolled %d die/dice → %s", + player or "anonymous player", + rolls_int, + results, + ) + + # Return a single number when only one die was rolled, otherwise an array. + if rolls_int == 1: + return PlainTextResponse(content=str(results[0])) + return JSONResponse(content=results) + + +if __name__ == "__main__": + # APPLICATION_PORT lets operators change the listening port without + # modifying code, e.g. when running inside a container. + port = int(os.environ.get("APPLICATION_PORT", 8080)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/telemetry.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/telemetry.py new file mode 100644 index 0000000000..ca3c7a03de --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/app/telemetry.py @@ -0,0 +1,184 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenTelemetry SDK initialization via declarative (file-based) configuration. + +This module loads ``otel-config.yaml`` and uses it as the single source of +truth for the telemetry pipeline configuration. + +────────────────────────────────────────────────────────────────────────────── +STATUS: The SDK's file configuration module (opentelemetry-sdk >= 1.41) can +parse and validate the YAML, but the released version does not yet wire up +end-to-end application. Specifically: + + • load_config_file() returns an OpenTelemetryConfiguration dataclass whose + nested fields (resource, tracer_provider, etc.) are still raw dicts rather + than typed model instances. + + • configure_logger_provider() has not been released yet (merged to main in + PR #4990, 2026-04-13). + + • A top-level configure_sdk(config) orchestrator is proposed in issue #5126 + but not yet implemented. + +Once the SDK ships configure_sdk() (or the individual configure_* functions +work with the loader output), this entire file can be reduced to: + + from opentelemetry.sdk._configuration.file import configure_sdk, load_config_file + config = load_config_file(str(_CONFIG_PATH)) + configure_sdk(config) + +Until then, this module parses the YAML for validation and endpoint extraction, +then constructs providers programmatically to match the declared config. + +The Python logging bridge (LoggingHandler) is not part of the declarative +config schema and will always require a small programmatic setup step. +────────────────────────────────────────────────────────────────────────────── + +Importing this module activates the SDK globally. +""" + +import logging +from pathlib import Path + +from opentelemetry import _logs, metrics, trace +from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( + OTLPMetricExporter, +) +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.logging.handler import LoggingHandler +from opentelemetry.sdk._configuration.file import load_config_file +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + ConsoleMetricExporter, + PeriodicExportingMetricReader, +) +from opentelemetry.sdk.resources import ( + OTELResourceDetector, + ProcessResourceDetector, + Resource, + SERVICE_NAME, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + SimpleSpanProcessor, +) + +_CONFIG_PATH = Path(__file__).resolve().parent.parent / "otel-config.yaml" + + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers for extracting values from the parsed config (raw dicts). +# These can be removed once configure_sdk() lands in the SDK. +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_resource(config) -> Resource: + """Build a Resource from the parsed config's resource section.""" + service_name = "unknown_service" + if config.resource and isinstance(config.resource, dict): + for attr in config.resource.get("attributes", []): + if attr.get("name") == "service.name": + service_name = attr["value"] + + base = Resource.create({SERVICE_NAME: service_name}) + process_resource = ProcessResourceDetector().detect() + otel_resource = OTELResourceDetector().detect() + return base.merge(process_resource).merge(otel_resource) + + +def _get_otlp_endpoint(provider_dict, signal_path) -> str | None: + """Extract the OTLP HTTP endpoint from a provider config dict.""" + if not isinstance(provider_dict, dict): + return None + items = provider_dict.get("processors") or provider_dict.get("readers") or [] + for item in items: + if not isinstance(item, dict): + continue + for processor_type in ("batch", "simple", "periodic"): + proc = item.get(processor_type) + if not isinstance(proc, dict): + continue + exporter = proc.get("exporter", {}) + if isinstance(exporter, dict) and "otlp_http" in exporter: + otlp = exporter["otlp_http"] + if isinstance(otlp, dict): + return otlp.get("endpoint") + return None + + +# ────────────────────────────────────────────────────────────────────────────── + + +def configure_opentelemetry() -> None: + """Load otel-config.yaml and install the configured SDK globally. + + Parses and validates the YAML via load_config_file(), then constructs + providers programmatically using endpoints from the parsed config. + """ + config = load_config_file(str(_CONFIG_PATH)) + resource = _build_resource(config) + + # ── Traces ── + tracer_provider = TracerProvider(resource=resource) + trace.set_tracer_provider(tracer_provider) + + endpoint = _get_otlp_endpoint(config.tracer_provider, "traces") + tracer_provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + + # ── Metrics ── + endpoint = _get_otlp_endpoint(config.meter_provider, "metrics") + metric_readers = [ + PeriodicExportingMetricReader(OTLPMetricExporter(endpoint=endpoint)), + PeriodicExportingMetricReader(ConsoleMetricExporter()), + ] + metrics.set_meter_provider( + MeterProvider(resource=resource, metric_readers=metric_readers) + ) + + # ── Logs ── + logger_provider = LoggerProvider(resource=resource) + _logs.set_logger_provider(logger_provider) + + endpoint = _get_otlp_endpoint(config.logger_provider, "logs") + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint)) + ) + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(ConsoleLogExporter()) + ) + + # ── Log bridge (always programmatic — not covered by the config schema) ── + otel_handler = LoggingHandler(logger_provider=logger_provider) + + console_handler = logging.StreamHandler() + console_handler.setFormatter( + logging.Formatter("%(levelname)s %(name)s - %(message)s") + ) + + root_logger = logging.getLogger() + root_logger.addHandler(otel_handler) + root_logger.addHandler(console_handler) + root_logger.setLevel(logging.DEBUG) + + +configure_opentelemetry() diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/library/__init__.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/library/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/library/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/library/rolldice.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/library/rolldice.py new file mode 100644 index 0000000000..e54435749d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/library/rolldice.py @@ -0,0 +1,163 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dice-rolling logic for the rolldice reference application. + +────────────────────────────────────────────────────────────────────────────── +IMPORTANT: This module imports ONLY from ``opentelemetry`` (the API package). +It does NOT import from ``opentelemetry.sdk`` or any SDK subpackage. + +This is intentional and reflects the recommended OTel library design: + + • Library code should depend only on the *API* so it can be used in + applications that choose any SDK implementation (or no SDK at all). + + • The *SDK* — which actually records spans, collects metrics, and exports + telemetry — is configured once by the *application* (in ``app/telemetry.py``). + + • When the application installs an SDK via ``set_tracer_provider()`` / + ``set_meter_provider()``, the API calls below automatically delegate to it. + Without an installed SDK the API calls are no-ops, so this library is safe + to use in any context. +────────────────────────────────────────────────────────────────────────────── +""" + +import logging +import random + +# opentelemetry.trace and opentelemetry.metrics are the *API* namespaces. +# They expose get_tracer() and get_meter(), which return a Tracer/Meter bound +# to whatever provider the application has installed (or a no-op if none). +from opentelemetry import metrics, trace + +# Observation is used to yield values from ObservableGauge callbacks. +from opentelemetry.metrics import Observation + +logger = logging.getLogger(__name__) + +# ── Tracer ─────────────────────────────────────────────────────────────────── +# get_tracer() returns a Tracer scoped to this instrumentation library. +# The name (__name__ = "library.rolldice") becomes the instrumentation scope +# name visible in trace backends; it is not the span name. +_tracer = trace.get_tracer(__name__) + +# ── Meter and instruments ──────────────────────────────────────────────────── +# get_meter() returns a Meter scoped to this library. Metric instruments +# (Counter, Histogram, ObservableGauge) are created once at module load time; +# creating them repeatedly would produce duplicate registrations. +_meter = metrics.get_meter(__name__) + +# Counter: monotonically increasing. Use it for things that are counted and +# never decrease, like the total number of dice rolls requested. +_calls_counter = _meter.create_counter( + "rolldice.calls", + description="Total number of roll_dice() calls", +) + +# Histogram: records a distribution of values. Use it to understand the +# spread of measurements — here, which dice faces (1-6) are landed on most. +_outcome_histogram = _meter.create_histogram( + "rolldice.outcome", + description="Distribution of individual dice outcomes (1–6)", +) + +# ObservableGauge: reports the current value of something at collection time +# via a callback. Prefer it over an UpDownCounter when you care about the +# instantaneous value, not the cumulative change. Here it tracks the most +# recent ``rolls`` argument so operators can see the last workload size. +_last_rolls_value: int = 0 + + +def _observe_last_rolls(options: metrics.CallbackOptions): + """Callback invoked by the SDK at each metric collection interval.""" + yield Observation(_last_rolls_value) + + +_last_rolls_gauge = _meter.create_observable_gauge( + "rolldice.last_rolls", + callbacks=[_observe_last_rolls], + description="Most recent value of the rolls parameter", +) + + +def roll_dice(rolls: int, player: str | None = None) -> list[int]: + """Roll a six-sided die ``rolls`` times and return the results. + + Args: + rolls: Number of dice to roll. Must be a positive integer. + player: Optional display name for the player (used in log output). + + Returns: + A list of ``rolls`` integers, each in the range [1, 6]. + + Raises: + ValueError: If ``rolls`` is not a positive integer. + """ + # with_span() is a context manager that: + # 1. Creates a new span named "roll_dice" as a child of the current + # active span (the HTTP request span created by FastAPIInstrumentor). + # 2. Sets it as the active span for the duration of the block. + # 3. Ends the span (and records any exception) when the block exits. + with _tracer.start_as_current_span("roll_dice") as span: + # Semantic convention attributes describe the code location that + # created the span. code.function and code.filepath are part of the + # "code" namespace in the OTel semantic conventions. + span.set_attribute("code.function", "roll_dice") + span.set_attribute("code.filepath", __file__) + + # Application-specific attribute: how many rolls were requested. + # Recording it on the span lets you filter traces by workload size. + span.set_attribute("rolls", rolls) + + if rolls <= 0: + # record_exception() attaches the exception details as a span event + # with the standard "exception.*" attributes, then we re-raise so + # the caller (the HTTP handler) can return a 500 response. + exc = ValueError(f"rolls must be a positive integer, got {rolls}") + span.record_exception(exc) + raise exc + + results = [_do_roll() for _ in range(rolls)] + + # Update the module-level variable observed by the gauge callback. + global _last_rolls_value + _last_rolls_value = rolls + + # Counter: increment by 1 for this call, regardless of roll count. + _calls_counter.add(1) + + # Histogram: record each individual outcome so the distribution of + # dice faces is captured. + for value in results: + _outcome_histogram.record(value) + + player_label = player or "anonymous player" + logger.debug("%s rolled %s → %s", player_label, rolls, results) + + return results + + +def _do_roll() -> int: + """Roll a single six-sided die and return the result. + + This inner function has its own span so the trace shows both the + aggregate ``roll_dice`` operation and each individual roll, making + it easy to see exactly how long each random number generation took. + """ + with _tracer.start_as_current_span("_do_roll") as span: + value = random.randint(1, 6) + # Record the generated value on the span so you can inspect individual + # roll results in a trace backend without needing to look at logs. + span.set_attribute("roll.value", value) + return value diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/otel-config.yaml b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/otel-config.yaml new file mode 100644 index 0000000000..6c97ee6b84 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/otel-config.yaml @@ -0,0 +1,72 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Declarative configuration for the rolldice reference application. +# +# NOTE: With the exception of env var substitution syntax, +# SDKs ignore environment variables when interpreting config files. +# +# Schema docs: https://github.com/open-telemetry/opentelemetry-configuration/blob/main/schema-docs.md + +file_format: "1.0" +disabled: false + +resource: + attributes: + - name: service.name + value: rolldice + attributes_list: ${OTEL_RESOURCE_ATTRIBUTES:-} + detection/development: + detectors: + - service: + - process: + +propagator: + composite: + - tracecontext: + - baggage: + +tracer_provider: + processors: + - batch: + exporter: + otlp_http: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318}/v1/traces + - simple: + exporter: + console: {} + sampler: + parent_based: + root: + always_on: + +meter_provider: + readers: + - periodic: + exporter: + otlp_http: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318}/v1/metrics + - periodic: + exporter: + console: {} + +logger_provider: + processors: + - batch: + exporter: + otlp_http: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318}/v1/logs + - batch: + exporter: + console: {} diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/pyproject.toml b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/pyproject.toml new file mode 100644 index 0000000000..b40fdaabc9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-reference-application-rolldice" +version = "0.1.0" +description = "OpenTelemetry reference application — rolldice (FastAPI)" +license = "Apache-2.0" +requires-python = ">=3.12" +dependencies = [ + # Web framework and ASGI server. + "fastapi >= 0.100", + "uvicorn[standard] >= 0.20", + + # OpenTelemetry API — the stable, vendor-neutral interface used by library + # code. Does not include any SDK or exporters. + "opentelemetry-api ~= 1.36", + + # OpenTelemetry SDK with file-configuration support — enables declarative + # setup from otel-config.yaml (brings in pyyaml and jsonschema). + "opentelemetry-sdk[file-configuration] ~= 1.36", + + # FastAPI instrumentation — auto-instruments FastAPI apps with HTTP server + # spans and metrics following the OTel HTTP semantic conventions. + "opentelemetry-instrumentation-fastapi ~= 0.57b0", + + # Python logging bridge — provides LoggingHandler, a stdlib logging.Handler + # that forwards Python log records to the OTel LoggerProvider. + "opentelemetry-instrumentation-logging ~= 0.57b0", + + # OTLP HTTP exporter — sends spans, metrics, and log records to any + # OTLP-compatible backend (OTel Collector, Jaeger, Tempo, etc.) over HTTP. + "opentelemetry-exporter-otlp-proto-http ~= 1.36", +] + +[tool.hatch.build.targets.wheel] +packages = ["app", "library"] diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/app/__init__.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/app/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/app/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/app/main.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/app/main.py new file mode 100644 index 0000000000..b732b5ff0a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/app/main.py @@ -0,0 +1,100 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastAPI application entry point (uninstrumented). + +This is the plain version of the rolldice application without any +OpenTelemetry instrumentation. Compare with the instrumented version +in the parent directory to see what OTel adds. +""" + +import logging +import os + +import uvicorn +from fastapi import FastAPI, Query, Response +from fastapi.responses import JSONResponse, PlainTextResponse + +from library import rolldice + +logging.basicConfig( + level=logging.DEBUG, + format="%(levelname)s %(name)s - %(message)s", +) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Rolldice", + description="OpenTelemetry reference application — rolls a six-sided die.", + version="0.1.0", +) + + +@app.get("/rolldice") +@app.post("/rolldice") +async def rolldice_endpoint( + rolls: str | None = Query(default=None), + player: str | None = Query(default=None), +) -> Response: + """Roll a six-sided die one or more times. + + Query parameters: + - ``rolls``: number of dice to roll (default: 1, must be a positive integer) + - ``player``: optional player name included in debug log output + + Returns a single number (1-6) when rolls is 1, or a JSON array of numbers + when rolls > 1. + """ + rolls_raw = rolls if rolls is not None else "1" + + try: + rolls_int = int(rolls_raw) + except ValueError: + logger.warning( + "HTTP 400: invalid rolls parameter %r (must be an integer)", + rolls_raw, + ) + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "Parameter rolls must be a positive integer", + }, + ) + + try: + results = rolldice.roll_dice(rolls_int, player) + except ValueError as exc: + logger.error( + "HTTP 500: library raised ValueError for rolls=%d — %s", + rolls_int, + exc, + ) + return PlainTextResponse(status_code=500, content="") + + logger.info( + "HTTP 200: %s rolled %d die/dice → %s", + player or "anonymous player", + rolls_int, + results, + ) + + if rolls_int == 1: + return PlainTextResponse(content=str(results[0])) + return JSONResponse(content=results) + + +if __name__ == "__main__": + port = int(os.environ.get("APPLICATION_PORT", 8080)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/library/__init__.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/library/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/library/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/library/rolldice.py b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/library/rolldice.py new file mode 100644 index 0000000000..48b5084c7b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-fastapi/examples/rolldice/uninstrumented/library/rolldice.py @@ -0,0 +1,54 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dice-rolling logic (uninstrumented). + +This is the plain version of the rolldice library without any OpenTelemetry +instrumentation. Compare with the instrumented version in the parent +directory to see what OTel adds. +""" + +import logging +import random + +logger = logging.getLogger(__name__) + + +def roll_dice(rolls: int, player: str | None = None) -> list[int]: + """Roll a six-sided die ``rolls`` times and return the results. + + Args: + rolls: Number of dice to roll. Must be a positive integer. + player: Optional display name for the player (used in log output). + + Returns: + A list of ``rolls`` integers, each in the range [1, 6]. + + Raises: + ValueError: If ``rolls`` is not a positive integer. + """ + if rolls <= 0: + raise ValueError(f"rolls must be a positive integer, got {rolls}") + + results = [_do_roll() for _ in range(rolls)] + + player_label = player or "anonymous player" + logger.debug("%s rolled %s → %s", player_label, rolls, results) + + return results + + +def _do_roll() -> int: + """Roll a single six-sided die and return the result.""" + return random.randint(1, 6)