# OpenTelemetry in Python

> Use the opentelemetry API in Python on Elide without an SDK: the vendored and installed API, spans, metrics, logging export, provider handover, and the elide.telemetry module.

Instrument code with the standard [`opentelemetry`](https://pypi.org/project/opentelemetry-api/) API. Elide
implements it: there is no SDK, exporter, or `opentelemetry-instrument` wrapper to add, and libraries that already
use the API work unchanged.

```python
# app.py
import logging
from opentelemetry import trace

tracer = trace.get_tracer("checkout")
with tracer.start_as_current_span("charge") as span:
    span.set_attribute("order.id", 42)
    logging.warning("card declined, retrying")
```

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 elide run app.py
```

## The API package

| Your environment | `import opentelemetry` resolves to |
| --- | --- |
| No `opentelemetry-api` installed | Elide's vendored copy (`opentelemetry-api` 1.45.1) |
| `opentelemetry-api` installed | Your copy, backed by Elide |

Elide installs its tracer, meter, and logger providers when your code first imports `opentelemetry.trace`,
`opentelemetry.metrics`, or `opentelemetry._logs`. Nothing is imported before your code does, and the import order
among those modules does not matter. The vendored copy
answers `importlib.metadata` like an installed wheel, so `OTEL_PROPAGATORS`, `OTEL_PYTHON_CONTEXT`, and the
`OTEL_PYTHON_*_PROVIDER` variables work as usual. When telemetry is not configured, the API returns its no-op
implementations.

## Traces

Tracers, spans, links, events, and status follow the API. Context is the API's `contextvars` context, so it follows
`with` blocks, threads that copy the context, and asyncio tasks.

```python
from opentelemetry import trace

tracer = trace.get_tracer("jobs")

@tracer.start_as_current_span("import rows")
def import_rows(rows):
    trace.get_current_span().set_attribute("rows", len(rows))
```

A span started with no active Python span is parented to the request Elide is serving on that thread, if any. Passing
an explicit `context=` starts a root span instead.

## Metrics

```python
from opentelemetry import metrics

pending = []
meter = metrics.get_meter("checkout")
orders = meter.create_counter("orders", unit="{order}")
latency = meter.create_histogram(
    "charge.duration", unit="s", explicit_bucket_boundaries_advisory=[0.01, 0.05, 0.1, 0.5, 1]
)

def queue_depth(options):
    yield metrics.Observation(len(pending))

meter.create_observable_gauge("queue.depth", callbacks=[queue_depth])

orders.add(1, {"region": "eu"})
latency.record(0.042)
```

All synchronous and observable instruments are supported, and callbacks may be generators. Callbacks run at each
collection.

## Logging

With `logs.console` on (the default), records that a `logging` handler would print are also exported as log records,
with the active span's ids. What `logging` prints is unchanged, and records no handler would emit are not exported.

| `logging` field | Log record |
| --- | --- |
| Logger name | Instrumentation scope |
| `levelno` | Severity: `DEBUG` 5, `INFO` 9, `WARNING` 13, `ERROR` 17, `CRITICAL` 21 |
| `getMessage()` | Body |
| `pathname`, `funcName`, `lineno` | `code.file.path`, `code.function.name`, `code.line.number` |
| `exc_info` | `exception.type`, `exception.message`, `exception.stacktrace` |

The logs API works too; records keep structured bodies (`dict`, `list`, `bytes`):

```python
from opentelemetry._logs import get_logger, LogRecord, SeverityNumber

get_logger("checkout").emit(
    LogRecord(severity_number=SeverityNumber.WARN, body={"event": "retry", "attempt": 2})
)
```

An uncaught exception is exported as an error with its Python traceback; see [Errors](https://elide.help/docs/opentelemetry/signals#errors).

## Using an OpenTelemetry SDK

An application can still set its own providers, from `opentelemetry-sdk` or `opentelemetry-instrument`:

| Event | Result |
| --- | --- |
| The application sets a tracer, meter, or logger provider | It takes over that signal and Elide prints a warning. Tracers, meters, loggers, and observable callbacks created earlier forward to it. |
| `OTEL_PYTHON_TRACER_PROVIDER` (or `_METER_`, `_LOGGER_`) names a provider | Elide never installs its own for that signal. |
| `elide.telemetry.release()` | Elide unsets its providers; the application's take over without warnings. |

```text
An OpenTelemetry SDK set its own tracer provider; Elide's Python tracer provider steps aside
```

Elide's runtime telemetry keeps recording either way.

## `elide.telemetry`

Controls the API does not cover. Stubs ship with Elide's Python type stubs; every function is safe to call when
telemetry is not configured.

```python
import elide.telemetry
from opentelemetry import trace

with trace.get_tracer("jobs").start_as_current_span("nightly"):
    print(elide.telemetry.current_span_context()["traceparent"])

elide.telemetry.flush()
print(elide.telemetry.status()["spans"])
```

| Function | Returns | Description |
| --- | --- | --- |
| `active()` | `bool` | Whether telemetry is configured for this process. |
| `flush(timeout_millis=5000)` | `bool` | Exports everything recorded so far, waiting up to `timeout_millis`. Whether every destination accepted it. |
| `status()` | `TelemetryStatus` | Approximate, process-wide pipeline counters; see below. |
| `current_span_context()` | `CurrentSpanContext \| None` | The active span's `trace_id`, `span_id`, `trace_flags`, and `traceparent`, or `None` outside a valid span. |
| `release()` | `bool` | Stops serving as the global OpenTelemetry implementation, so a provider set afterwards owns every signal. Elide's runtime instrumentation keeps recording. `False` when already released or never installed. |

```python
class TelemetryStatus(TypedDict):
    active: bool
    released: bool
    stepped_aside: list[str]  # "trace", "metrics", "logs"
    spans: SpanCounters  # started, unsampled, ended, live, exported, dropped, table_full, reclaimed,
    #                      handed_off, exit_dropped
    logs: LogCounters  # emitted, filtered, exported, dropped, handed_off, exit_dropped
    metrics: MetricCounters  # handed_off, exit_dropped
    export_failures: int
    exports_abandoned: int

class CurrentSpanContext(TypedDict):
    trace_id: str
    span_id: str
    trace_flags: int
    traceparent: str
```

## Limits

- Requests served by Elide do not set the Python `contextvars` context; spans parent to the request through Elide
  instead, as described in [Traces](#traces).
- Python HTTP clients do not get client spans or `traceparent`; inject it with
  `opentelemetry.propagate.inject(headers)`.

## Samples

- [Python span and logging](https://elide.help/docs/code-samples#python-span-and-logging)
