Skip to main content
This page is the reference for setting Laminar-recognized OpenTelemetry attributes from a non-SDK exporter: foreign-language SDKs, browser code, custom instrumentations, internal trace adapters. If you are using @lmnr-ai/lmnr or lmnr (Python), the SDK already sets these attributes for you. Use this page when you ship OTLP directly to Laminar without the SDK and need the literal wire keys.

How attributes flow into Laminar

Spans arrive at Laminar as standard OTLP. Laminar reads three layers:
  • Resource attributes on the tracer provider (service.name, service.version, deployment.environment).
  • Span attributes on each span: span shape, LLM telemetry, GenAI semconv content.
  • Trace-association attributes, which Laminar lifts off any span in the trace and stores once on the trace record (session, user, metadata, tags, trace type).
Trace-association attributes use an “any-span wins” merge: the first non-empty value Laminar sees during ingest is what the trace gets. The most predictable place to set them is the root span, set them once.

Span-shape attributes

Set on the span you want to control. All keys are namespaced under lmnr.span.*. Setting lmnr.span.type = "LLM" is required for LLM-specific UI and cost rollups to render at all. A span with LLM type but no gen_ai.* content shows up in the LLM section but with an empty conversation panel. See LLM attributes below.

Trace-association attributes

These describe the trace, not the individual span. Set them once on the root span: Laminar lifts them onto the trace record at ingest time. Setting them on every span works but is wasteful. The metadata key shape mirrors what the Laminar SDK emits. To attach {environment: "prod", featureFlag: "new-algo", abVariant: {bucket: 3}} to a trace, set three attributes on the root span:

LLM attributes

Set these on a span where lmnr.span.type = "LLM".

Provider, model, tokens, cost

Cost is calculated from a model name and token counts. Laminar uses gen_ai.response.model when it is set and falls back to gen_ai.request.model, so either one is enough; without a model name cost stays at zero. gen_ai.system is not required, but it narrows the price lookup and is what custom model costs match on. Setting the explicit gen_ai.usage.*_cost keys overrides the calculated values and skips every price table. See LLM cost tracking.

Cached, reasoning, and audio tokens

Laminar prices these at their own rates rather than folding them into input and output. The Laminar integrations set the cache and reasoning keys wherever the provider reports them; the rest are here for exporters that have the numbers and want them priced. gen_ai.usage.input_tokens is the total input count, cached tokens included. Laminar subtracts the cache read and write counts from it to get the tokens billed at the regular input rate, so do not add cached tokens on top of the input total. Service tier is read from gen_ai.response.service_tier first, then gen_ai.request.service_tier, then the openai.* and anthropic.* equivalents. A tier that the model publishes a rate for, such as flex or priority, is priced at that rate; gen_ai.request.batch does the same for batch rates.
The current OTel spec forms (gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_creation.input_tokens) and pydantic-ai’s gen_ai.usage.details.cache_read_tokens / cache_write_tokens are normalized to the keys above on ingestion, so either form works.

Prompts and completions

Use the OTel GenAI semconv message-array form. This is the convention Laminar recommends for new exporters. Each parts entry is one of {type: "text", content}, {type: "thinking", content}, {type: "tool_call", id, name, arguments}, {type: "tool_call_response", id, response}, {type: "uri", uri}, {type: "blob", blob, mimeType}. Laminar preserves the message shape end-to-end and the frontend renders each part inline.
The indexed gen_ai.prompt.{i}.* / gen_ai.completion.{i}.* shape (older OpenLLMetry / OpenInference convention) is still ingested for backwards compatibility but is deprecated. New exporters should emit the semconv form above.

Tool definitions

The older llm.request.functions.{i}.{name,description,parameters} indexed form is still ingested for backwards compatibility but is deprecated. New exporters should emit gen_ai.tool.definitions.

Where to put each attribute

Resource-level attributes are sent once per batch and apply to every span in that batch; putting service.name on each span instead is wasteful and harder to filter. For trace-association attributes, root-span placement is simpler than every-span placement and produces the same result.

Worked example: a minimal correct trace without the SDK

The example below uses only @opentelemetry/api and @opentelemetry/sdk-trace-*, no @lmnr-ai/lmnr import. The Python snippet uses the same plain OTel APIs. Both build the same trace shape: an outer agent.run span with session metadata, a child llm.chat LLM span with full GenAI attributes, and a child tool.execute TOOL span.
GenAI keys (gen_ai.*) are part of the OpenTelemetry GenAI semantic conventions; everything under lmnr.* is Laminar-specific.

Anti-patterns

  • Setting lmnr.association.properties.session_id on every span. Set it once on the root; Laminar lifts it onto the trace at ingest. Repeating it everywhere wastes attribute bytes.
  • Putting service.name or app version on each span. These are Resource attributes. Set them on the tracer provider’s Resource so they’re emitted once per batch.
  • Setting lmnr.span.type = "EXECUTOR" on application code. EXECUTOR is reserved for auto-instrumentation and the evaluations framework. Use DEFAULT or TOOL.
  • Setting LLM token counts without gen_ai.system + gen_ai.request.model. Cost stays at zero. The provider name and request model are required to look up pricing.
  • Setting lmnr.span.type = "LLM" but omitting prompt and completion attributes. The span renders as an LLM call but the conversation panel is empty. Emit gen_ai.input.messages and gen_ai.output.messages.
  • Passing non-primitive attribute values directly. OTel attribute values are limited to string | number | boolean | string[] | number[] | boolean[]. For lmnr.span.input, lmnr.span.output, per-message content, or non-primitive metadata values, JSON.stringify first.

Transports for /v1/traces

The Laminar cloud (api.lmnr.ai) and self-hosted backends accept three OTLP transports:
  • OTLP/gRPC on :8443 (cloud) / :8001 (self-hosted): the recommended path.
  • OTLP/HTTP+protobuf on :443 (cloud) / :8000 (self-hosted): Content-Type: application/x-protobuf.
  • OTLP/HTTP+JSON on :443 (cloud) / :8000 (self-hosted): Content-Type: application/json. Useful for browser SDKs and other runtimes that don’t have a protobuf encoder available; spec quirks (hex or base64 IDs, decimal-stringified fixed64, enum-name strings) are accepted.
Pick whichever your runtime supports best. Attribute semantics on the wire are identical across transports.

What’s next

OpenTelemetry transport setup

Endpoints, ports, headers, and the gRPC vs HTTP comparison.

Span types

What DEFAULT, LLM, and TOOL render to in the transcript view.

LLM cost tracking

Required gen_ai.* attributes and supported provider names.

Metadata

Trace-level metadata, including the wire key for non-SDK exporters.