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.
openinference.span.kind, so Phoenix labels them unknown. Token counts on a Pixeltable span are a pixeltable.udf.tokens span event, which Phoenix does not count as token usage. The OpenInference span is the one Phoenix treats as the LLM call.
This page matches Pixeltable 0.7.19,
arize-phoenix 20.24.0, arize-phoenix-otel 0.17.2, and openinference-instrumentation-openai 0.1.65. pixeltable[otel] installs opentelemetry-instrumentation-pixeltable 0.1.0.Choose an approach
Install
pixeltable[otel] installs the Pixeltable bridge, the OpenTelemetry SDK, and the OTLP exporters. arize-phoenix-otel is the Phoenix client used by register(). openinference-instrumentation-openai records LLM spans for pixeltable.functions.openai. For another provider, install that provider’s OpenInference instrumentor instead. openai is the client that chat_completions() calls. Neither Pixeltable nor the instrumentor installs it.
To run Phoenix on this machine, install the server and start it. The UI and OTLP/HTTP share port 6006. OTLP/gRPC listens on 4317. A local phoenix serve has no API key.
Set the environment
A local server needs the model key only:register() sends Authorization: Bearer with PHOENIX_API_KEY.
Export spans to Phoenix
pxt_otel.init() builds the tracer provider and starts Pixeltable’s spans. For OTLP/HTTP it appends /v1/traces unless the endpoint already ends in that path, so pass the base URL. Set metrics=False. An endpoint otherwise also posts metrics to /v1/metrics.
Call pxt_otel.init() once in the process, before the first Pixeltable operation. The default protocol is http/protobuf.
x-project-name routes the export to a project. Phoenix reads it on OTLP/HTTP only, and it takes precedence over the resource attribute. gRPC does not read that header. For gRPC, set the resource attribute and call init() like this instead. pxt_otel.init() builds its resource with Resource.create, which merges OTEL_RESOURCE_ATTRIBUTES:
headers is comma-separated key=value pairs, the OTEL_EXPORTER_OTLP_HEADERS form.
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 header value from environment variables, and never write the key into code. On a hosted database, run pxt secret set pxt://<org>:<db> OTEL_EXPORTER_OTLP_HEADERS='Authorization=Bearer <api-key>,x-project-name=pixeltable-app', then pxt db restart pxt://<org>:<db> and pxt service restart pxt://<org>:<db>/<service>. Configuration.pxt.table, pxt.path, pxt.column, pxt.udf_path, and, for a resource-pool call, pxt.resource_pool and pxt.retries. The model, prompt, and completion are on the OpenInference span. 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 LLM span 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 LLM span nests under pixeltable.store.insert_rows.
Setting only OTEL_EXPORTER_OTLP_TRACES_ENDPOINT does not start export. Set OTEL_EXPORTER_OTLP_ENDPOINT or pass endpoint.
Declare the table and insert
An application file puts the columns on aTableModel. 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.
- Application file
- Script
pxt_otel.init():--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 Phoenix endpoint on that entry instead.
--otel calls pxt_otel.init() and instruments FastAPI. It does not instrument OpenAI, and an OpenAIInstrumentor().instrument() in another process does not carry into this one. Put this at module scope in app.py. The service loads the file after init(), so the instrumentor attaches to that provider:
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 this path or the Phoenix SDK in a process, not both.
Use the Phoenix SDK
register() installs its tracer provider as the global OpenTelemetry provider (set_global_tracer_provider=True by default). auto_instrument=True turns on OpenInference instrumentors that are already installed, including openinference-instrumentation-openai when that package is installed. Pixeltable’s instrumentor is not one of them, so attach it yourself. Do this once, before the first Pixeltable operation. Do not also call OpenAIInstrumentor().instrument(). auto_instrument=True already wrapped OpenAI, and a second call returns immediately: BaseInstrumentor is a singleton.
project_name is exported as openinference.project.name. That selects the Phoenix project for the gRPC export register() uses by default (localhost:4317).
If gRPC to the server is blocked, pass the OTLP/HTTP URL and protocol='http/protobuf'. Without that protocol, a PHOENIX_COLLECTOR_ENDPOINT that ends in /v1/traces is sent to port 4317 with that path, and no spans arrive.
register() uses a simple span processor unless you pass batch=True. Flush either way: trace.get_tracer_provider().force_flush().
View the traces
Open thepixeltable-app project and select the pixeltable.insert trace. Phoenix shows pixeltable.udf.chat_completions beside pixeltable.row, labeled unknown, with pxt.udf_path and pxt.column. The ChatCompletion span under the UDF span is the OpenInference generation: model, prompt, completion, and token counts.
span_level='info' omits pixeltable.row and pixeltable.udf.*. The operation, catalog, store, and SQL spans remain, and the generation nests under pixeltable.store.insert_rows.
Caveats
- With the bridge on, every Pixeltable operation is a trace, including
create_dir,create_table,drop_dir, andcollect.span_level='info'drops the per-row, per-UDF, and per-file spans. - 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 oninsert, onupdate, and whenadd_computed_columnbackfills existing rows. - Flush before the process exits:
trace.get_tracer_provider().force_flush().pxt_otel.init()uses a batch span processor, so a script can finish while spans are still queued. - Call
pxt_otel.init(), orregister(), and enable the instrumentors before the first Pixeltable operation.pxt_otel.init()runs once per process. - Prompts and completions are sent to Phoenix on the OpenInference span.
logsdefaults to off. Leave it off unless those log lines are redacted.
Observability
Span tree, metrics, and a Grafana Cloud export.
Exporter and OTLP
Phoenix collector ports, gRPC, and HTTP.