Monitor usage with OpenTelemetry
Syntic Code can send a stream of events and usage counters to the monitoring stack you already run. Point it at an OpenTelemetry collector, a Prometheus server, or just your terminal, and you can chart sessions, tokens and estimated spend per team, model or repository.
Export is off by default. Nothing is sent anywhere until you turn it on, and the destination is always one you choose.
How it works
When a session starts, Syntic Code reads a handful of environment variables. If export is switched on and at least one exporter is named, it starts collecting:
- Events (the OpenTelemetry logs signal): one record per prompt, tool decision, tool result, model request, model error and hook run.
- Metrics: counters for sessions, estimated cost and tokens.
Events are buffered and sent in batches. A batch leaves every 5 seconds by default, as soon as 100 events are waiting, and once more when the session ends, so short -p runs are not lost. Metrics are sent as delta sums every 60 seconds by default, and at exit.
Three things must line up for anything to be exported:
SYNTIC_ENABLE_TELEMETRYis on (1,true,yesoron).OTEL_LOGS_EXPORTERand/orOTEL_METRICS_EXPORTERnames an exporter.- The privacy level is
standard. Any setting that lowers it (SYNTIC_PRIVACY=essential,SYNTIC_OFFLINE=1,SYNTIC_DISABLE_NONESSENTIAL_TRAFFIC=1,SYNTIC_TELEMETRY=0,DO_NOT_TRACK=1orDISABLE_AUTOUPDATER=1) switches export off, whatever else is set. See Data usage.
Quick start
Turn export on
export SYNTIC_ENABLE_TELEMETRY=truePick exporters
Choose one for events and one for metrics. You can set only one of the two.
export OTEL_LOGS_EXPORTER=otlp # or console
export OTEL_METRICS_EXPORTER=otlp # or console, or prometheusPoint at your collector
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.internal.example:4318
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer%20<token>"Try it with short intervals
While testing, send batches every second so you see data quickly. Remove these once it works.
export OTEL_LOGS_EXPORT_INTERVAL=1000
export OTEL_METRIC_EXPORT_INTERVAL=1000
syntic -p "list the files in this folder"To check the setup without a collector, use the console exporter. Each batch is printed to standard error as one line of JSON, wrapped as {"otel": "logs" | "metrics", "data": …}:
SYNTIC_ENABLE_TELEMETRY=1 OTEL_LOGS_EXPORTER=console \
syntic -p "say hello" 2> otel.jsonlUse console with -p runs. In the interactive screen, output on standard error would draw over the interface.
Configuration reference
| Variable | What it does | Default |
|---|---|---|
SYNTIC_ENABLE_TELEMETRY | Master switch for export. | off |
OTEL_LOGS_EXPORTER | Exporter for events: otlp or console (logging is accepted as another name for console). | none |
OTEL_METRICS_EXPORTER | Exporter for metrics: otlp, console or prometheus. | none |
OTEL_EXPORTER_OTLP_ENDPOINT | Base address of the collector. /v1/logs and /v1/metrics are added to it. | http://localhost:4318 |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | Full URL for events. Used as given, nothing is added. | |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT | Full URL for metrics. Used as given. | |
OTEL_EXPORTER_OTLP_HEADERS | Headers sent with every batch, as key=value,key2=value2. Values are percent-decoded, so write a space as %20. | |
OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_METRICS_HEADERS | Extra headers for one signal only, added after the shared ones. | |
OTEL_EXPORTER_OTLP_PROTOCOL | Wire protocol. Only http/json is supported. | http/json |
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL, OTEL_EXPORTER_OTLP_METRICS_PROTOCOL | The same, for one signal. | |
OTEL_LOGS_EXPORT_INTERVAL | Milliseconds between event batches (100 or more). | 5000 |
OTEL_METRIC_EXPORT_INTERVAL | Milliseconds between metric batches (100 or more). | 60000 |
OTEL_RESOURCE_ATTRIBUTES | Your own attributes on every record, key=value,…, percent-decoded. | |
OTEL_LOG_USER_PROMPTS | Include the prompt text in syntic.user_prompt events. | off |
OTEL_LOG_TOOL_DETAILS | Include tool arguments in syntic.tool_result events. | off |
OTEL_INCLUDE_SESSION_ID | Add session.id to every record. | off |
SYNTIC_OTEL_MAX_CONTENT_CHARS | Longest text kept in any field; longer text ends in …[truncated]. | 2048 |
OTEL_EXPORTER_PROMETHEUS_HOST | Address the Prometheus endpoint listens on. | 127.0.0.1 |
OTEL_EXPORTER_PROMETHEUS_PORT | Port of the Prometheus endpoint. | 9464 |
SYNTIC_PROMETHEUS_ALLOW_REMOTE | Allow a Prometheus host other than loopback. | off |
SYNTIC_PROMETHEUS_FILE | Write the Prometheus text to this file on every metrics interval. |
A choice of grpc or http/protobuf turns that signal off and logs a warning, rather than sending data your collector cannot read. Choosing prometheus for events does the same, since Prometheus carries metrics only. Batches that fail to send are dropped quietly and never interrupt your session.
Roll it out to a team
The variables only take effect from places you control:
- Your shell or your deployment tooling. Export them in a login profile, a CI job, or a wrapper script.
- User settings. The
envblock in~/.syntic/settings.jsoncan carry them. - Organization policy. Put them in the
envblock of a managed settings file, so every user on the machine reports to the same collector.
A repository can not set any OTEL_* variable, SYNTIC_ENABLE_TELEMETRY or any SYNTIC_TELEMETRY* variable through its .syntic/settings.json. Those are dropped, so a cloned project cannot quietly switch reporting on or redirect it to a host of its choosing.
A managed policy that sends everything to one collector might look like this:
{
"env": {
"SYNTIC_ENABLE_TELEMETRY": "1",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://otel.internal.example:4318",
"OTEL_RESOURCE_ATTRIBUTES": "department=platform,cost_center=eng-42"
}
}Split by team
Use OTEL_RESOURCE_ATTRIBUTES to tag records with whatever you group by. Give each team its own policy file in settings.policy.d/, or set the variable in each team’s shell profile:
export OTEL_RESOURCE_ATTRIBUTES="team=payments,department=engineering"Your attributes are added last, so they can also replace a built-in attribute such as service.name if your collector expects a different value.
What is exported
Resource attributes
Every event and metric carries these:
| Attribute | Value |
|---|---|
service.name | syntic-code |
service.version | The CLI version. |
os.type | macos, linux or windows. |
host.arch | The processor architecture, such as aarch64 or x86_64. |
vcs.repository.name | The name of the folder that holds .git, when the session runs inside a repository. |
session.id | The session id, only with OTEL_INCLUDE_SESSION_ID. |
| Your attributes | Everything in OTEL_RESOURCE_ATTRIBUTES. |
Session ids are left out by default because every session would become its own series, which makes metrics expensive to store.
Events
Each event is a log record with severity INFO, a body equal to the event name, and an event.name attribute with the same value.
| Event | When | Attributes |
|---|---|---|
syntic.user_prompt | You send a prompt in the main conversation (prompts a subagent receives are not counted). | prompt_length; prompt only with OTEL_LOG_USER_PROMPTS. |
syntic.tool_decision | A tool call is allowed or refused. | tool_name, decision (accept or reject), source (allowed, or policy for a refusal). |
syntic.tool_result | A tool call finishes. | tool_name, success, duration_ms, error when it failed; tool_parameters only with OTEL_LOG_TOOL_DETAILS. |
syntic.api_request | A model request completes. | model, cost_usd, duration_ms, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens. |
syntic.api_error | A request ends in an error after its retries. | model, error, attempt. |
syntic.hook_execution | A hook finishes. | hook_event (such as PreToolUse), outcome (answered or no_answer), duration_ms. |
cost_usd is the same estimate the CLI shows in /cost: list price, or your organization’s modelPricing rates if a policy sets them. See Manage costs.
Metrics
All metrics are monotonic sums with delta temporality: each batch holds what was counted since the previous one.
| Metric | Unit | Attributes | Counts |
|---|---|---|---|
syntic.session.count | sessions | none | One per session start. |
syntic.cost.usage | US dollars | model | Estimated cost of each model request. |
syntic.token.usage | tokens | type (input, output, cacheRead, cacheCreation), model | Tokens per request; a type with zero tokens is not recorded. |
Prometheus
With OTEL_METRICS_EXPORTER=prometheus, metrics are kept as running totals instead of being pushed. Names follow Prometheus rules: dots become underscores and counters end in _total, so syntic.token.usage becomes syntic_token_usage_total. Attributes become labels.
There are two ways to collect them:
| Mode | Set | Result |
|---|---|---|
| Scrape endpoint | nothing extra, or OTEL_EXPORTER_PROMETHEUS_HOST / _PORT | http://127.0.0.1:9464/metrics serves the totals while the session runs. |
| Text file | SYNTIC_PROMETHEUS_FILE=/var/lib/node_exporter/syntic.prom | The file is rewritten on every metrics interval, ready for a textfile collector. No port is opened unless you also set a port. |
The endpoint only listens on a loopback address unless you set SYNTIC_PROMETHEUS_ALLOW_REMOTE=1, so usage numbers do not become visible to the network by accident. A host that is not loopback, without that switch, turns metrics off with a warning.
Because each session is its own process, the scrape endpoint suits a long interactive session on one machine. For many short runs, the text file or the OTLP exporter works better.
Example collector setup
A minimal OpenTelemetry Collector configuration that accepts what Syntic Code sends over HTTP and prints it:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
logs:
receivers: [otlp]
exporters: [debug]
metrics:
receivers: [otlp]
exporters: [debug]Swap the debug exporter for your log store and metrics backend once you see records arriving.
Traces from CI and wrapper scripts
When Syntic Code is started inside a traced job, pass the job’s span in the standard TRACEPARENT variable (and TRACESTATE if you use it). Two things happen:
- Every exported event carries that trace id, so records line up with the rest of the job in your tracing tool.
- Each shell command the agent runs gets its own
TRACEPARENT: the same trace id with a fresh span id. Tools in that command that understand trace context can attach their spans under the agent’s run.
A malformed TRACEPARENT, or one with an all-zero trace or span id, is ignored.
Privacy
- Prompt text and tool arguments are not exported unless you opt in with
OTEL_LOG_USER_PROMPTSandOTEL_LOG_TOOL_DETAILS. Without them, only the prompt’s length and the tool’s name are sent. - Error messages are exported, cut to
SYNTIC_OTEL_MAX_CONTENT_CHARS. - Your sign-in credential is never included.
- Export goes only to the endpoints you configure. Syntic does not receive a copy.
Troubleshooting
| Symptom | Check |
|---|---|
| Nothing arrives | Is SYNTIC_ENABLE_TELEMETRY set, and an exporter named? Is the privacy level standard? syntic doctor shows the level on its Network line. |
The variables are in .syntic/settings.json but nothing happens | Project settings cannot set them. Move them to your shell, user settings or a managed policy. |
| Events arrive but no metrics, or the reverse | Each signal has its own exporter variable. Also check that the protocol is http/json. |
| Data shows up a minute late | Metrics go out every 60 seconds. Lower OTEL_METRIC_EXPORT_INTERVAL while testing. |
| The Prometheus endpoint never opens | The port may be taken, or the host is not loopback without SYNTIC_PROMETHEUS_ALLOW_REMOTE. Run with --debug to see the warning. |
| Too many series in your metrics store | Leave OTEL_INCLUDE_SESSION_ID off and keep OTEL_RESOURCE_ATTRIBUTES to a few stable values. |
Related
- Manage costs: how the cost figures are estimated and how to cap spend.
- Usage and billing: what counts against your plan.
- Network configuration: proxies and certificates for the CLI’s own traffic.
- Environment variables: every variable in one place.