Metadata-Version: 2.4
Name: acceldata-aio-tracer
Version: 1.1.3
Summary: Acceldata AIO instrumentation SDK — OpenTelemetry-native LLM application telemetry
License: Apache-2.0
Author: Acceldata
Requires-Python: >=3.10,<4.0
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Provides-Extra: db
Provides-Extra: http
Requires-Dist: opentelemetry-api (>=1.44.0,<2.0.0)
Requires-Dist: opentelemetry-exporter-otlp-proto-http (>=1.44.0,<2.0.0)
Requires-Dist: opentelemetry-instrumentation (>=0.65b0,<1.0.0)
Requires-Dist: opentelemetry-instrumentation-aiopg (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-instrumentation-asyncpg (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-instrumentation-dbapi (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.65b0,<1.0.0) ; extra == "http"
Requires-Dist: opentelemetry-instrumentation-httpx (>=0.65b0,<1.0.0) ; extra == "http"
Requires-Dist: opentelemetry-instrumentation-mysql (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-instrumentation-mysqlclient (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-instrumentation-psycopg2 (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-instrumentation-pymssql (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-instrumentation-pymysql (>=0.65b0,<1.0.0) ; extra == "db"
Requires-Dist: opentelemetry-sdk (>=1.44.0,<2.0.0)
Requires-Dist: opentelemetry-semantic-conventions (>=0.65b0,<1.0.0)
Requires-Dist: pydantic (>=2.0.0,<3.0.0)
Requires-Dist: requests (>=2.31.0,<3.0.0)
Requires-Dist: wrapt (>=1.16.0,<2.0.0)
Description-Content-Type: text/markdown

# Acceldata AIO Python SDK

Trace model calls, agents, and tools in Acceldata AIO using OpenTelemetry.
Initialize once at startup, then wrap each chat turn or workflow in a
`root_span()` to group its spans and attribute them to a conversation and user.

## Installation

Requires Python 3.10 or later.

```sh
pip install acceldata-aio-tracer
```

### Optional instrumentation

Extras install OpenTelemetry instrumentation packages that the SDK activates
during `aio.init()`. Choose them based on the libraries your application uses:

| Extra | What it enables | When to install |
| --- | --- | --- |
| `http` | Incoming FastAPI request spans and outgoing httpx request spans. | Your application uses FastAPI or httpx and you want HTTP calls included in traces. |
| `db` | SQL spans for psycopg2, PyMySQL, mysql-connector-python, asyncpg, aiopg, mysqlclient, and pymssql. | Your application uses one of these drivers and you want database tracing and automatic SQL-based data-source analysis. |
| `db` | DB-API tracing for Snowflake, Databricks, Trino, and PyAthena. | Your application queries these systems through their Python connectors, directly or through SQLAlchemy. PyAthena has not yet been validated against a live Athena service. |

```sh
pip install 'acceldata-aio-tracer[http]'
pip install 'acceldata-aio-tracer[db]'
# Or install both:
pip install 'acceldata-aio-tracer[http,db]'
```

The base SDK already includes instrumentation for psycopg v3 and BigQuery;
these do not need `[db]`. Install your database drivers, model clients, and
frameworks separately—the extras supply instrumentation, not those libraries.

Without an extra (or its instrumentation packages installed separately), the
corresponding calls are not automatically traced by the SDK. SQL-based
data-source analysis needs spans containing query text and database connection
attributes; a root or model span alone does not provide that information.
Call `init()` before constructing clients or opening database connections.

## Quickstart

This example uses Anthropic; AIO also instruments other supported providers and
frameworks. Install the client:

```sh
pip install anthropic
```

Initialize with your **project UUID**, then create the model client. Replace the
placeholder values with your configuration and a model you can access.
For omitted arguments, see the [environment fallbacks](#configuration).

```python
import acceldata_aio_tracer as aio
from anthropic import Anthropic

aio.init(
    service="support-agent",
    tenant_id="your-tenant-id",
    project_id="your-project-uuid",
    endpoint="https://aio.example.com",
    access_key="your-access-key",
    secret_key="your-secret-key",
)
client = Anthropic(api_key="your-anthropic-api-key")

question = "What can AI observability tell me about my application?"
with aio.root_span(
    conversation_id="session-123",
    user_id="user-456",
    input_preview=question,
):
    response = client.messages.create(
        model="your-model-id",
        max_tokens=256,
        messages=[{"role": "user", "content": question}],
    )
    for block in response.content:
        if block.type == "text":
            print(block.text)
```

Use one `root_span()` per turn or workflow. Reuse the conversation ID across
turns in the same conversation; `user_id` and `input_preview` are optional.
For workflows, pass a fixed name such as `name="workflow_run"`; keep IDs out
of span names.

## Async usage

`root_span()` uses a regular `with` block, including inside async functions:

```python
import asyncio

import acceldata_aio_tracer as aio
from anthropic import AsyncAnthropic

aio.init(
    service="support-agent",
    tenant_id="your-tenant-id",
    project_id="your-project-uuid",
    endpoint="https://aio.example.com",
    access_key="your-access-key",
    secret_key="your-secret-key",
)


async def main():
    async with AsyncAnthropic(api_key="your-anthropic-api-key") as client:
        with aio.root_span(conversation_id="session-123"):
            response = await client.messages.create(
                model="your-model-id",
                max_tokens=256,
                messages=[{"role": "user", "content": "Explain distributed tracing."}],
            )
            for block in response.content:
                if block.type == "text":
                    print(block.text)


asyncio.run(main())
```

## Configuration

Pass values directly to `aio.init()`. If an argument is omitted, the SDK reads
the environment fallback below. Nonempty arguments take precedence.

| `init()` argument | Environment fallback when omitted | Purpose |
| --- | --- | --- |
| `service` | `OTEL_SERVICE_NAME` | Application name; defaults to `unknown_service`. |
| `tenant_id` | `AIO_TENANT_ID` | Required tenant ID. |
| `project_id` | `AIO_PROJECT_ID` | Project UUID; skips project lookup. |
| `project_name` | None; pass explicitly. | Resolve a project by name instead of UUID. |
| `endpoint` | `AIO_ENDPOINT` | AIO server URL; also the default telemetry destination. |
| `aio_collector_endpoint` | `AIO_COLLECTOR_ENDPOINT` | Override when the Collector has a separate URL. |
| `access_key`, `secret_key` | `AIO_ACCESS_KEY`, `AIO_SECRET_KEY` | AIO credentials. |
| `environment` | `AIO_ENVIRONMENT` | Deployment environment; defaults to `default`. |
| `redaction` | See [redaction settings](docs/content-redaction.md#environment-settings). | Control which recorded content is withheld. |
| `data_source_hosts` | `AIO_DATA_SOURCE_HOSTS` | Map connection addresses to catalog addresses; format: `from=to,from=to`. |

The Collector URL also falls back to `OTEL_EXPORTER_OTLP_ENDPOINT` before
`endpoint`. Existing OpenTelemetry setups can supply `aio.tenant_id`,
`aio.project_id`, and `deployment.environment` through `OTEL_RESOURCE_ATTRIBUTES`;
these take precedence over their `AIO_*` environment variables.

Export defaults differ from stock OpenTelemetry so that bursts are not dropped
inside your app: the span queue holds 10,000 spans instead of 2,048 (uploads
still go every 512), and traces, logs and metrics are sent gzip-compressed. The
standard variables override them: `OTEL_BSP_MAX_QUEUE_SIZE` for the queue, and
`OTEL_EXPORTER_OTLP_COMPRESSION` or `OTEL_EXPORTER_OTLP_<SIGNAL>_COMPRESSION`
(`none`, `gzip`, `deflate`) for compression.

To select a **project by name** instead of UUID, replace your initial `init()`
call with:

```python
import acceldata_aio_tracer as aio

aio.init(
    service="support-agent",
    tenant_id="your-tenant-id",
    project_name="customer-support",
    endpoint="https://aio.example.com",
    access_key="your-access-key",
    secret_key="your-secret-key",
)
```

Supply exactly one of `project_id` or `project_name`. When using a name, also
unset `AIO_PROJECT_ID` and any `aio.project_id` resource attribute. A name is
matched exactly; if no project matches, the SDK creates an Acceldata-hosted
project with 60-day retention.

# Content redaction

Redaction changes recorded telemetry, not model requests, tool arguments, or
application return values. Configure it in your first `aio.init()` call:

```python
import acceldata_aio_tracer as aio

aio.init(
    service="support-agent",
    tenant_id="your-tenant-id",
    project_id="your-project-uuid",
    endpoint="https://aio.example.com",
    access_key="your-access-key",
    secret_key="your-secret-key",
    redaction=aio.redaction_policy(
        hide_system_instructions=True,
        hide_tool_definitions=True,
        redact_tool_arguments=["run_query"],
        redact_tool_results=["lookup_customer"],
        redact_sql_literals=True,
    ),
)
```

`aio.redaction_policy(...)` builds an immutable `aio.RedactionPolicy`. Options
default to off. Rules apply to supported content attributes on exported spans
and GenAI log records, including tool calls embedded in model messages.

## Policy options

| Option | What it does |
| --- | --- |
| `hide_system_instructions=True` | Replace the system-instructions attribute with a fingerprint and remove system-role messages from message arrays. |
| `hide_tool_definitions=True` | Replace recorded tool definitions with a fingerprint. |
| `redact_tool_arguments=["tool_name"]` | Withhold arguments for the named tools. Names must match those recorded by instrumentation. |
| `redact_tool_results=["tool_name"]` | Withhold results separately from arguments. If a message does not identify which tool produced its result, all tool-result messages in that payload are withheld. |
| `redact_attributes=["attribute.name"]` | Withhold entire named attributes, such as `aio.input_preview`, `function.args`, or `function.kwargs`. |
| `suppress_spans=["scratchpad"]` | Drop matching spans and their descendant spans. Matches the full name or the part after its first space, e.g. `scratchpad` matches `invoke_agent scratchpad`. |
| `redact_sql_literals=True` | Replace SQL string and numeric literals while retaining identifiers for data-source detection. SQL comments remain unchanged. |
| `patterns=[r"secret-[A-Za-z0-9]+"]` | Replace regex matches in supported message text and tool arguments/results with `__REDACTED__`. Accepts strings or compiled regexes. |
| `allow=lambda match: match == "secret-example"` | Keep a regex match when the predicate returns `True`; it receives only the matched string. |

Whole withheld values generally become `sha256:...` fingerprints, allowing
identical content to be recognized across calls. These are deterministic hashes,
not encryption. Pattern replacement uses `__REDACTED__` instead.

## Mark tool arguments with a decorator

Use the initialized policy above, then mark tools where they are defined:

```python
@aio.redact_arguments(name="run_query")
def run_query(connection, sql):
    cursor = connection.cursor()
    cursor.execute(sql)
    return cursor.fetchall()


# Register a tool whose implementation you do not control:
aio.redact_arguments(name="third_party_lookup")
```

`@aio.redact_arguments` also works without parentheses on a function or tool
class: the SDK uses its `name` or function/class name. Use explicit `name=` when
the framework exposes a different tool name. The decorator returns the original
object; it does not register a framework tool or create a span.

Tool marks are process-wide and add to `redact_tool_arguments`. Import or mark
tools before their telemetry is emitted. **A mark alone does not activate the
policy**: use an active policy, such as the one above. Results remain visible
unless separately covered by `redact_tool_results`.

## Hide model inputs within a function or block

This scope works without a global redaction policy, provided AIO installed its
own tracing provider:

```python
@aio.redact_model_inputs
async def private_completion(client, prompt):
    return await client.messages.create(
        model="your-model-id",
        max_tokens=256,
        messages=[{"role": "user", "content": prompt}],
    )


def private_completion_sync(client, prompt):
    with aio.redact_model_inputs():
        return client.messages.create(
            model="your-model-id",
            max_tokens=256,
            messages=[{"role": "user", "content": prompt}],
        )
```

Both `@aio.redact_model_inputs` and `@aio.redact_model_inputs()` support sync and
async functions. Use a regular `with` inside async code too. For generators, put
the `with` block inside the generator; decorating a generator raises `TypeError`.

The scope fingerprints input messages and system instructions for model spans
and log records marked `chat` or `text_completion`. Model outputs, tool content,
graph-node content, and root-span `input_preview` are not hidden by this scope.
Use policy options for those fields. Async tasks created within the scope
inherit it.

## LangGraph node content

`aio.langgraph_node` opts a node into recording its input state and return value.
Decorate before registering the function with LangGraph. It works on sync and
async nodes and relies on AIO's LangGraph instrumentation to create the span.

```python
@aio.langgraph_node
def normalize_request(state):
    return {"question": state["question"].strip()}


@aio.langgraph_node(
    input=lambda state: {"question": state["question"]},
    output=lambda state, result: result["answer"],
)
async def answer_node(state):
    response = await state["model"].ainvoke(state["question"])
    return {"answer": response.content}
```

Without selectors, the full state and return value are recorded, up to 100,000
characters per side. A selector returning `None` omits that side; a failing
selector is logged and omitted. If the node raises, only its input is recorded.
Recording follows the instrumentation's content-capture setting and the global
redaction policy. `redact_model_inputs()` does not hide node content.

## Environment settings

If `init(redaction=...)` is omitted, these variables build the policy. An explicit
policy replaces these settings rather than merging with them.

| Variable | Equivalent policy option |
| --- | --- |
| `AIO_HIDE_SYSTEM_INSTRUCTIONS=true` | `hide_system_instructions=True` |
| `AIO_HIDE_TOOL_DEFINITIONS=true` | `hide_tool_definitions=True` |
| `AIO_REDACT_TOOL_ARGUMENTS=run_query,lookup` | `redact_tool_arguments=["run_query", "lookup"]` |
| `AIO_REDACT_TOOL_RESULTS=lookup_customer` | `redact_tool_results=["lookup_customer"]` |

Other policy options require Python configuration.

## Coverage and initialization

Call `aio.init()` before another library installs an OpenTelemetry
`TracerProvider`. An active policy raises `aio.RedactionUnavailable` if AIO
cannot enforce it on an existing provider. A scoped `redact_model_inputs()` call
instead warns when span redaction is unavailable; its log-record path remains
covered when log setup succeeded.

Rules cover the documented attributes, not arbitrary logs, exception messages,
or every copy of a value. Regex patterns do not automatically scrub
`aio.input_preview`, `function.args`, custom attributes, or SQL text. Use
`redact_attributes` for whole fields and `redact_sql_literals` for SQL values.
Suppressing a parent span also removes useful descendant spans, so choose names
carefully.

For the interaction between SQL redaction and catalog matching, see
[Data-source detection].


# Data-source detection

The SDK records database calls. The AIO server parses their SQL to derive table
reads, writes, and column usage, then matches references to catalog assets.
The SDK does not query the catalog or register data sources itself.

## Automatic SQL detection

Install your database client and any required [instrumentation extra](../README.md#optional-instrumentation).
Initialize AIO before opening connections. For example, with psycopg v3:

```python
import acceldata_aio_tracer as aio
import psycopg

aio.init(
    service="orders-agent",
    tenant_id="your-tenant-id",
    project_id="your-project-uuid",
    endpoint="https://aio.example.com",
    access_key="your-access-key",
    secret_key="your-secret-key",
    redaction=aio.redaction_policy(redact_sql_literals=True),
    data_source_hosts={"localhost:5432": "orders-db.example.com:5432"},
)

with aio.root_span(conversation_id="session-123", name="lookup_order"):
    with psycopg.connect("postgresql://app:password@localhost:5432/sales") as connection:
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT id, total FROM public.orders WHERE id = %s",
                (42,),
            )
            print(cursor.fetchone())
```

No per-query SDK call is required. SQLAlchemy is covered through supported,
instrumented underlying drivers; using SQLAlchemy alone does not add coverage
for an unsupported driver.

The server needs SQL text (`db.query.text` or `db.statement`), database system,
and connection identity. Unsupported SQL or missing required identity can
prevent extraction. Use schema-qualified table names where possible.

## Map tunnels and connection aliases

Use `init(data_source_hosts={...})` when the application connects through a
tunnel, private alias, or proxy that differs from the catalog's address. In the
example above, `localhost:5432` maps to the registered PostgreSQL host.

- A `host:port` key matches that endpoint; a host-only key matches any port.
- A `host:port` match takes precedence over a host-only match.
- A target without a port keeps the driver-reported port.
- The original connection address remains in telemetry; the mapping supplies a
  separate address for catalog matching.

If the argument is omitted, `AIO_DATA_SOURCE_HOSTS` accepts comma-separated pairs:
`localhost:5432=orders-db.example.com:5432,tunnel=snowflake-account.snowflakecomputing.com`.
An explicit map replaces the environment map. Host mapping requires AIO's span
exporter; initialize AIO before configuring another OpenTelemetry provider.

## Declare reads and writes explicitly

Use `aio.declare_assets(reads=..., writes=...)` when access goes through a tool,
API, or custom connector whose SQL the SDK cannot observe. Call it inside the
span that performs the access. With AIO already initialized:

```python
with aio.root_span(conversation_id="session-123"):
    with aio.start_trace("lookup_orders"):
        aio.declare_assets(
            reads=[("snowflake://acme-prod", "SALES.PUBLIC.ORDERS")],
            writes=[("snowflake://acme-prod", "SALES.PUBLIC.AUDIT")],
        )
        # Perform the corresponding data access here.
```

A tuple is `(namespace, name)`. To include columns or other details, use a dict:

```python
with aio.start_trace("read_order_totals"):
    aio.declare_assets(
        reads=[{
            "namespace": "snowflake://acme-prod",
            "name": "SALES.PUBLIC.ORDERS",
            "source_type": "snowflake",
            "kind": "table",
            "columns": ["ID", "TOTAL"],
        }],
    )
```

| Field | Meaning |
| --- | --- |
| `namespace` | Required source identity, e.g. `postgres://orders-db.example.com:5432` or `snowflake://acme-prod`. |
| `name` | Required qualified asset name, e.g. `sales.public.orders` or `SALES.PUBLIC.ORDERS`. |
| `source_type` | Optional source type, e.g. `postgres`, `snowflake`, or `sqlserver`. |
| `kind` | Optional object kind: `table`, `view`, `file`, `endpoint`, or `collection`. Catalog resolution still depends on server support. |
| `columns` | Optional list of column names; duplicates are removed. |
| `evidence` | Optional explanation supplied by the application. |

Declarations attach only to the current span; they do not propagate to child
spans or create spans. Repeated calls add references and deduplicate identical
ones. With no recording span, the call does nothing. A declaration describes
access; it neither executes a query nor grants access to the data.

## Detection and catalog matching

| Database | SQL reference extraction | Catalog asset matching |
| --- | --- | --- |
| PostgreSQL, Redshift, Snowflake, Databricks, MySQL, SQL Server | Supported dialects. | Supported when the source and asset are registered and their identities match. |
| Trino | ANSI SQL parsing; dialect-specific statements may be skipped. | Supported when catalog identities match. |
| BigQuery | Supported dialect. | Not currently implemented. |
| Athena | ANSI SQL parsing; PyAthena instrumentation has not been validated against a live service. | Not currently implemented. |

Catalog resolution runs separately from trace ingestion. A visible SQL span
does not guarantee a resolved asset: the source may be unregistered, its address
may differ, the asset may be absent, or multiple catalog sources may match.
Declarations also need matching namespace/name values; they do not bypass these
requirements.

## Preserve detection while hiding SQL values

`aio.redaction_policy(redact_sql_literals=True)` turns:

```sql
SELECT total FROM public.orders WHERE email = 'alice@example.com' AND id = 42
```

into:

```sql
SELECT total FROM public.orders WHERE email = '?' AND id = ?
```

Table and column identifiers remain available for parsing. SQL comments remain
unchanged. If you hide the whole `db.query.text` / `db.statement` attribute or
suppress the database span, the server cannot derive references from that SQL;
use explicit declarations on a retained span when appropriate.

Hiding tool arguments alone does not hide SQL copied onto database spans. Use
both the tool redaction and SQL literal options when both channels carry values
you want withheld. See [Content redaction](content-redaction.md).


## Tracing helpers

These helpers are available through `import acceldata_aio_tracer as aio`.

| Helper | Usage |
| --- | --- |
| `aio.root_span(...)` | Group one turn or workflow and propagate conversation/user attribution. |
| `aio.current_conversation_id()` | Get the active conversation ID, or `None` outside a conversation scope. |
| `with aio.start_trace("lookup") as span:` | Add a child operation; use `span.set_metadata({...})` or `span.set_result(value)`. |
| `@aio.trace` | Trace a synchronous function and record arguments and its return value. For async functions, use a `with aio.start_trace(...)` block around the awaited work. |
| `@aio.langgraph_node` | Opt a LangGraph node into input/output capture; see [node examples](docs/content-redaction.md#langgraph-node-content). |
| `with aio.using_attributes({...}):` | Add attributes to supported auto-instrumented calls; also works as a synchronous-function decorator. |
| `aio.inject_additional_attributes(fn, {...})` | Call a synchronous zero-argument function with those attributes in context. |
| `with aio.agent_context("support"):` | Set agent-name context for supported instrumentation. |
| `with aio.agent_version_context("v2"):` | Set agent-version context for supported instrumentation. |
| `with aio.using_tags("key:value"):` | Stamp `"key:value"` on spans started inside the scope; also a sync or async decorator. Adds to an enclosing set. |
| `aio.set_tags(...)` | Replace the current tag set. `set_tags()` with no arguments clears. |
| `aio.current_tags()` | Tags that would be stamped on a span started now. |

## Tags

A tag is a `"key:value"` label the application already knows when the turn
starts — `"escalated:yes"`, `"region:eu"`. It uses the same context-variable
mechanism as the conversation id: set it for a unit of work and it is stamped
on every span started inside, whichever library produced the span. There is no
process-wide default. A tag belongs to the work, not to the process.

`using_tags` is the usual call. It adds to whatever an enclosing scope already
set, and restores that outer set on exit — as a context manager or as a
decorator on a sync or async function:

```python
with aio.root_span(conversation_id="session-123"):
    with aio.using_tags("region:eu"):
        ...
```

```python
@aio.using_tags("workflow:nightly")
def run_nightly():
    ...
```

`set_tags` replaces the set rather than adding to it. `set_tags()` with no
arguments clears. Use it when this turn's tags should not inherit.

Tags apply to spans started after they are set; they do not rewrite a span that
is already open. A generator function cannot take `@using_tags` — use `with`
inside it, the same restriction as redaction.

A tag must be `"key:value"` with both sides nonblank, and at most 200
characters. Duplicates are collapsed; at most 32 tags are kept. Anything that
fails those rules is logged at warning and dropped, not raised, so a bad tag
cannot break the application. Empty `using_tags()` is the exception: that is
API misuse and raises `TypeError`. `current_tags()` returns what would be
stamped on a span started now.

## Troubleshooting

- **Initialization failed:** `init()` returns `False` and logs the reason for
  ordinary setup failures. Check the tenant ID, project selector, endpoint, and
  credentials. A redaction policy that cannot be enforced raises
  `aio.RedactionUnavailable`.
- **Missing instrumentation:** call `init()` before constructing clients. Install
  the appropriate [optional instrumentation](#optional-instrumentation) for
  your HTTP libraries or database drivers.
- **Configuration changes have no effect:** after successful initialization,
  subsequent `init()` calls return `True` without reconfiguring the SDK.
- **Missing tags:** set tags before the span starts. `init()` does not apply a
  process-wide default; tags belong to the unit of work.

## More examples

See the repository examples for [LangChain chat](examples/langchain_examples/basic_chat.py),
[streaming](examples/langchain_examples/streaming_chat.py), and a
[LangGraph workflow](examples/langchain_examples/langgraph_analyst.py).
They use the shared [telemetry setup](examples/telemetry.py),
[environment template](examples/.env.example), and a separate
[dependency configuration](examples/pyproject.toml).

## License

Apache-2.0. Includes instrumentation from [OpenLIT](https://github.com/openlit/openlit);
see [UPSTREAM.md](src/acceldata_aio_tracer/UPSTREAM.md) for attribution and changes.

