Skip to main content

Send OTel traces to AcruxCore with the SDK helper

What you'll do: wire the SDK's register() helper into a few different OpenTelemetry (OTel) setups — a bare pipeline, one auto-instrumented framework, several frameworks at once, and a multi-turn session — so traces reach AcruxCore's OTLP endpoint with no hand-written OTel plumbing. Both published SDKs ship this helper — acruxcore.otel.register() in Python, register() from @acruxcoreai/sdk/otel in Node — pick a language tab wherever the code differs.

AcruxCore accepts traces from any OTel source at POST /api/v1/traces/otlp — that part isn't new, and register() doesn't change what the endpoint accepts. It collapses the TracerProvider + BatchSpanProcessor + OTLPSpanExporter wiring every OTLP integration needs into one call, and optionally turns on a named framework's own OpenInference instrumentor against the result. If you'd rather not add the SDK as a dependency, the OTLP API reference shows the same four lines written out by hand — both reach the identical endpoint.

pip install 'acruxcore[otel]'
export ACRUXCORE_API_KEY=<your-acruxcore-key>
export ACRUXCORE_BASE_URL=https://api.acruxcore.com/api/v1

register() reads those two variables by default — pass apiKey/baseUrl (Python: api_key/base_url) instead if you'd rather not use env vars.

1. A bare pipeline, no framework​

The plainest case: no instrumentor, just the pipeline, and a span you create and name yourself. Useful for a step no framework instrumentor covers — a retrieval call, a custom pre-processing stage:

from acruxcore.otel import register
from opentelemetry import trace

tracer_provider = register(service_name="my-script")
tracer = trace.get_tracer("my-script")

with tracer.start_as_current_span("hello-otel") as span:
span.set_attribute("input.value", "hello from a hand-rolled span")
...

tracer_provider.force_flush()

That lands in AcruxCore exactly as written — no model, no cost, because there was none:

{
"kind": "other",
"name": "hello-otel",
"status": "unset",
"durationMs": 100,
"payload": { "input": "hello from a hand-rolled span", "output": null }
}

A span with no recognized kind attribute lands as "other". For a more specific kind, set either openinference.span.kind ("CHAIN", "RETRIEVER", ...) or the OpenTelemetry GenAI attribute gen_ai.operation.name ("chat", "retrieval", "execute_tool", ...). The OTLP API reference lists how each value maps.

2. Auto-instrument one framework​

Pass instrument=[...] (Node: instrument: [...]) and register() turns on that framework's own OpenInference instrumentor against the provider it just built — nothing else in your code needs to change:

from openai import OpenAI
from acruxcore.otel import register

tracer_provider = register(service_name="my-script", instrument=["openai"])

client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say hi in exactly two words."}],
)
print(response.choices[0].message.content)

tracer_provider.force_flush()

The resulting span carries the real model, provider, token counts, and cost. openinference-instrumentation-openai reads the model and token counts straight off the OpenAI response. AcruxCore then prices the span when it arrives, as described in Where the cost comes from:

{
"kind": "llm",
"name": "ChatCompletion",
"model": "gpt-4o-mini-2024-07-18",
"provider": "openai",
"promptTokens": 14,
"completionTokens": 3,
"costUsd": 0.0000039
}

instrument=[...] only instruments an already-installed package — it never installs anything for you. Passing a name whose package isn't installed raises AcruxCoreError naming the exact package to install; passing a name outside the supported list raises a different error listing the ones that are:

from acruxcore.otel import SUPPORTED_FRAMEWORKS

print(SUPPORTED_FRAMEWORKS)
# ('crewai', 'langchain', 'llama_index', 'openai', 'openai_agents')

3. Auto-instrument several frameworks at once​

instrument takes a list because some frameworks need more than one instrumentor to see the whole picture. CrewAI is the clearest example: it orchestrates agents and tools itself, but calls the model through the plain openai Python SDK by default (the litellm integration is an opt-in extra most installs don't have) — so "crewai" alone sees the orchestration but not token counts or cost:

tracer_provider = register(
service_name="my-crew",
instrument=["crewai", "openai"],
)

This is exactly what the CrewAI trip-planner tutorial does, end to end, with a real web-search tool and a real multi-agent crew.

Node's shorter SUPPORTED_FRAMEWORKS (see section 2) has no framework needing this combination yet — the OpenAI Agents SDK tutorial's Node version gets full model, token, and cost data from instrument: ['openai_agents'] alone.

4. Group multiple calls into one session​

openinference.instrumentation.using_session() sets a value on the current OTel context that OpenInference instrumentors read and stamp onto every span they create — so it works for free once you've auto-instrumented a framework:

from openinference.instrumentation import using_session

with using_session("customer-42-conversation"):
first = client.chat.completions.create(model="gpt-4o-mini", messages=[...])
second = client.chat.completions.create(model="gpt-4o-mini", messages=[...])

Both calls above land in AcruxCore as separate traces sharing one session.id — open Observability → Sessions and both show up grouped, the same way the OpenAI Agents SDK tutorial groups its two-turn conversation.

Session grouping only affects spans an OpenInference instrumentor creates — a bare hand-rolled span like the one in section 1 doesn't go through an instrumentor, so it won't pick up the context value. Set the attribute yourself instead:

with tracer.start_as_current_span("first-call") as span:
span.set_attribute("session.id", "customer-42-conversation")

5. A framework not in SUPPORTED_FRAMEWORKS​

If a framework you use ships an OpenInference or plain-OTel instrumentor that isn't in the registry, skip instrument=[...] and wire it yourself against the provider register() already built:

from some_other_package import SomeInstrumentor

tracer_provider = register(service_name="my-script")
SomeInstrumentor().instrument(tracer_provider=tracer_provider)

This is the same object either path returns — instrument=[...] is a shortcut for the five frameworks in SUPPORTED_FRAMEWORKS, not a different mechanism.

Where the cost comes from​

The instrumentation reports the model and the token counts, not a price. AcruxCore adds the cost when the span arrives, in this order:

  1. A costUsd you sent on the span yourself.
  2. The input and output price on one of your Models, when its public name or its upstream id equals the span's model, such as glm-5.3.
  3. AcruxCore's built-in price list.

A model that matches none of these is stored with no cost. To price it, open Gateway → Models and add the model with its input and output prices. Every span sent after that gets a cost; spans already stored keep the cost they had. A model needs a provider connection, even when you only add it to set a price.

What's next​