Skip to main content
Pixeltable runs LLM calls inside computed columns and UDFs. Langfuse records each call as a trace: prompt, completion, model, token usage, cost, and latency. This page sends Pixeltable’s OpenTelemetry spans to Langfuse. The bridge itself, including metrics, is covered in Observability. One Pixeltable operation (insert, add_computed_column, collect, and the rest) is one trace. At span_level='debug' an OpenAI insert looks like this. openai.chat_completions runs on a resource pool, so its UDF span sits beside pixeltable.row. A UDF without a resource pool nests under pixeltable.row.
This page matches Pixeltable 0.7.19, Langfuse SDK 5.0.4, and openinference-instrumentation-openai 0.1.65. pixeltable[otel] installs opentelemetry-instrumentation-pixeltable.

Choose an approach

Install

pixeltable[otel] installs the Pixeltable bridge, the OpenTelemetry SDK, and the OTLP exporters. openai is the client that chat_completions() calls; neither Pixeltable nor the instrumentor installs it. Exporting spans uses the bridge and the OpenAI instrumentor. The Langfuse SDK path adds the langfuse package. Tracing LLM calls only needs langfuse and, optionally, the OpenAI instrumentor.

Set the environment

Keys belong to one Langfuse project and one region. A key from a US project on the EU host returns 401. Set LANGFUSE_BASE_URL. LANGFUSE_HOST still works and is deprecated.

Export spans to Langfuse

pxt_otel.init() builds the tracer provider and starts Pixeltable’s spans. Langfuse accepts OTLP/HTTP at LANGFUSE_BASE_URL + /api/public/otel, with HTTP Basic auth of public_key:secret_key. Call pxt_otel.init() once in the process, before the first Pixeltable operation. The default protocol is http/protobuf, which is what /api/public/otel expects. With that protocol the exporter appends /v1/traces to endpoint. If the config sets grpc, pass protocol='http/protobuf'.
A pxt_otel.init() argument overrides the config. With the argument left unset, endpoint and protocol resolve from the database entry (db_exporter_otlp_endpoint, db_exporter_otlp_protocol in pixeltable.toml), then OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL, then the [otel] section of ~/.pixeltable/config.toml. headers resolves from OTEL_EXPORTER_OTLP_HEADERS, then [otel]. Build the Authorization value from environment variables, as the example does, and never write the keys into code. On a hosted database, run pxt secret set pxt://<org>:<db> OTEL_EXPORTER_OTLP_HEADERS='Authorization=Basic <token>', then pxt service restart. Configuration.
Pixeltable spans record which operation, row, column, and UDF ran: pxt.table, pxt.path, pxt.column, pxt.udf_path, and, for a resource-pool call, pxt.resource_pool and pxt.retries. Token usage and cost are on the generation, which the OpenAI instrumentor adds. While an operation span is active, Pixeltable sets the OpenTelemetry context for that UDF call, including across its thread pools and async scheduler, so the generation nests under the UDF span. The span name is pixeltable.udf. plus the UDF’s display name, so this column is pixeltable.udf.chat_completions. pxt.udf_path is the full path, pixeltable.functions.openai.chat_completions. span_level='trace' adds a span per iterator step. At the default 'info', row and UDF spans are omitted, and the generation nests under pixeltable.store.insert_rows.
Anthropic SDK 2.0.0 emits its own anthropic.messages.create spans, including gen_ai.* usage, to the global provider. Langfuse records them as generations with cost. Leave openinference-instrumentation-anthropic uninstalled: enabling it beside those spans records each call twice and counts the cost twice.
Other OpenInference or OpenLLMetry instrumentors that attach to the global provider follow the same pattern.

Declare the table and insert

An application file puts the columns on a TableModel. pxt schema update app.py my_app creates my_app.articles. The insert runs in the process that called pxt_otel.init(). A script can create the same column with create_table and add_computed_column in that same process. Both forms store the completion text. The model runs when the row is inserted.
In the process that called pxt_otel.init():
To trace a served app, add --otel to pxt service run or pxt service update (Tracing). It calls pxt_otel.init() with no arguments before it loads the app file, so configure the export in the environment, or in the matching [otel] keys:
pxt service run reads these variables in its own process. pxt service update starts the service from the Pixeltable daemon, which reads the environment once, so run pxt daemon restart after the exports. If the database entry in pixeltable.toml sets db_exporter_otlp_endpoint, it wins over OTEL_EXPORTER_OTLP_ENDPOINT, so set the Langfuse endpoint on that entry instead. The app file must not call pxt_otel.init(): with --otel, that second call raises INVALID_STATE, and without --otel it records Pixeltable spans but not the FastAPI request spans.

Use the Langfuse SDK

Use the Langfuse client as the tracer provider when you also want @observe, propagate_attributes (trace name, tags, metadata, user or session ids), or one trace around a batch. Create the client first and pass should_export_span. Since SDK 4, Langfuse exports Langfuse spans, gen_ai.* spans, and known LLM-instrumentor spans. Pixeltable’s tracer name is pixeltable, so those spans are dropped until the filter allows them. Call PixeltableInstrumentor().instrument(), which attaches to the global provider the client installed. Export spans to Langfuse instead calls pxt_otel.init() to build a provider. Use one of the two in a process.

Trace your own UDFs

Put @pxt.udf above @observe. A .py file run as a script cannot define a UDF in __main__ (RequestError: Defining the UDF ... directly in the global namespace of a Python script is not allowed). Put the functions in a module and import them.
pxt schema update app.py my_app creates my_app.docs. In a notebook, the same assignments are t.add_computed_column(words=word_count(t.body)) and t.add_computed_column(title=headline(t.body, t.id)). Sync and async UDFs both record. The @observe span nests under the pixeltable.udf. span, and an LLM call made inside it nests under the @observe span.

One trace for a batch

Each Pixeltable operation starts its own trace. Wrap the call to choose the name and attach metadata. propagate_attributes metadata values are strings of 200 characters or fewer.
pixeltable.insert, its row and UDF spans, the @observe spans, and every generation land in that trace. Each carries table in its metadata. The same with wraps add_computed_column when that call backfills rows already in the table. Pixeltable’s own spans do not include row ids or cell values. To attach an id, pass the column into a UDF and call propagate_attributes() inside it, as headline() does.

Trace LLM calls only

Skip the Pixeltable bridge when the generations are enough.
  • import langfuse.openai before the first Pixeltable operation. The import patches openai, including the AsyncOpenAI client that pixeltable.functions.openai uses. Each computed-column call becomes an OpenAI-generation with tokens and cost.
  • Or create a Langfuse client and call OpenAIInstrumentor().instrument().
Each insert then produces one trace per LLM call. To group a batch under one trace, use the Langfuse SDK approach above. Without the Pixeltable bridge, a notebook or any caller with a running event loop runs the query on a worker thread that does not carry the Langfuse context.

View the traces

Open the project in Langfuse and go to Tracing. Filter by trace name (ingest-docs), tags, or metadata (table). A trace shows the span tree, the prompt and completion of each generation, and token and cost totals.

Caveats

  • With the bridge on, every Pixeltable operation is a trace, including create_dir, create_table, drop_dir, and collect. span_level='info' drops the per-row, per-UDF, and per-file spans. On an insert, the generation then nests under pixeltable.store.insert_rows.
  • Per-row spans stop at 100 rows in one operation. A provider call on a resource pool still gets its pixeltable.udf.* span.
  • Computed-column values are stored. collect() records a Pixeltable span and does not call the model again. The model runs on insert, on update, and when add_computed_column backfills existing rows.
  • Flush before the process exits: trace.get_tracer_provider().force_flush() after pxt_otel.init(), langfuse.flush() with the Langfuse client.
  • Call pxt_otel.init(), or create the Langfuse client, and enable the instrumentors before the first Pixeltable operation. pxt_otel.init() runs once per process.
  • Instrument each provider once. The Anthropic SDK’s spans plus OpenInference Anthropic, or langfuse.openai plus OpenAIInstrumentor, record two generations and count the cost twice.
  • Prompts and completions are sent to Langfuse. logs defaults to off. Leave it off unless those log lines are redacted.

Observability

Span tree, metrics, and a Grafana Cloud export.

Configuration

OTEL_EXPORTER_OTLP_* and the [otel] section.
Last modified on October 22, 2026