Signal catalogue
Status: draft M0. The single list of all telemetry names, units and attributes. Rules:
17-observability.md. What is missing here, the code must not send;
the collector allowlist, dashboards, rules and SLOs use only names from here.
1. Names and units
- Own names begin with
bothy.; names from the Semantic Conventions stay unchanged.
- Names in lowercase with dots; the unit is in the
unit field (UCUM), never in the name.
- Counters count events (
{event}, {run}), durations are seconds (s), sizes are bytes (By).
- Prometheus translates on OTLP ingestion: dots become
_, the unit is appended (s
becomes _seconds, By becomes _bytes), counters get _total, units in curly
braces are dropped. job is <service.namespace>/<service.name>, instance is
service.instance.id (https://prometheus.io/docs/guides/opentelemetry/, retrieved 2026-10-10)
[verified]. Checked locally with Prometheus v3.15.0 and otelcol-contrib 0.162.0 on 2026-10-10:
bothy.delivery.duration (histogram, s) becomes bothy_delivery_duration_seconds_bucket,
bothy.message.created (counter) becomes bothy_message_created_total, integer
bucket boundaries appear without decimal places (le="1", le="5") [verified].
- The "Prometheus" column below is the translation; dashboards and rules use it.
2. Cardinality
- Metric attributes come only from closed sets in the code (enumerations) or from sets
that the instance operator limits (agent handles, push providers, event types, actions from the
catalogue). Never IDs, never input from people, never error texts, never untemplated paths.
http.route is always the pattern (/api/v1/threads/{id}/messages). Requests without a matching route
carry http.route = "unmatched".
- IDs (thread, channel, message, run, file) appear only in spans and logs.
- A unit test counts the possible values of every metric attribute; a new open attribute
breaks the test.
- Instruments from libraries (otelhttp, otelpgx) run through an SDK view with an
allowlist of exactly the attributes of this catalogue. Reason: otelhttp sets further attributes,
for example
server.address from the client's Host header, which is an open set.
3. Resource attributes
| Attribute |
Value |
Source |
service.name |
bothy-server, bothy-backupd, bothy-adapter-<kind> |
process |
service.namespace |
bothy |
process |
service.version |
version of the artifact |
build |
service.instance.id |
unique per process (container hostname plus start time) |
process |
deployment.environment.name |
dev, staging, prod |
instance.environment or OTEL_RESOURCE_ATTRIBUTES |
bothy.server.role |
api, worker, all |
server.role |
Prometheus exposes these attributes of a process as the labels of the series target_info
(translated like the names in section 1, item 4); dashboards and alerts may use it to count
processes or to detect a missing one (M1-68).
4. Metrics
4.1 REST and gateway
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
http.server.request.duration |
Histogram |
s |
http.request.method, http.route, http.response.status_code, error.type |
otelhttp (Semantic Conventions HTTP, stable) |
http_server_request_duration_seconds |
bothy.authz.decisions |
Counter |
{decision} |
bothy.action, bothy.decision |
api: decisions for requests and ops; filters at delivery and in lists (07 section 4.2) do not count, otherwise delivery would mask every other number |
bothy_authz_decisions_total |
bothy.authz.errors |
Counter |
{check} |
bothy.reason (unknown_action, authz_not_checked, filter_mismatch) |
api: program errors of the permission check (02-architecture.md 9.3, 07 section 4.2); filter_mismatch: an element passed the SQL filter of a list but not authorize and was discarded (07 section 7.4, rule 3) |
bothy_authz_errors_total |
bothy.ratelimit.rejected |
Counter |
{request} |
bothy.ratelimit.class |
api |
bothy_ratelimit_rejected_total |
bothy.maintenance.rejected |
Counter |
{request} |
bothy.action |
api: requests that maintenance mode (server.maintenance_mode = read_only) answered with 503 maintenance (05-actions.md rule 1.9, ADR 0065, M7-08); never audited |
bothy_maintenance_rejected_total |
bothy.console.csp_violations |
Counter |
{report} |
bothy.csp.directive, bothy.csp.disposition |
api: one per violation report that submitCspReport accepted (19-security.md 5 rule 6, M6-03); within rl.public_auth |
bothy_console_csp_violations_total |
bothy.gateway.connections.active |
UpDownCounter |
{connection} |
bothy.principal.kind |
api |
bothy_gateway_connections_active |
bothy.gateway.connect.duration |
Histogram |
s |
bothy.outcome (ok, client_error, server_error), bothy.principal.kind |
api: upgrade until ready or resumed |
bothy_gateway_connect_duration_seconds |
bothy.gateway.catchup.duration |
Histogram |
s |
bothy.outcome (complete, resync_required) |
api: ready/resumed until catchup.complete |
bothy_gateway_catchup_duration_seconds |
bothy.gateway.resync |
Counter |
{connection} |
bothy.reason (retention, too_far_behind, cursor_ahead, epoch_changed) |
api |
bothy_gateway_resync_total |
bothy.gateway.close |
Counter |
{connection} |
bothy.close.code |
api |
bothy_gateway_close_total |
bothy.gateway.outbound.dropped |
Counter |
{frame} |
bothy.reason (patch_replaced_by_snapshot, slow_consumer) |
api |
bothy_gateway_outbound_dropped_total |
bothy.gateway.presence.write_failures |
Counter |
{write} |
none |
api |
bothy_gateway_presence_write_failures_total |
client_error covers the close codes 4001 to 4008, 4011, 4012, 4016 to 4018 and
client aborts before ready; everything else without ready or resumed is server_error (for example
4000, 4013). Reason: waiting devices, revoked sessions and protocol errors of the client
must not consume the error budget of bothy-sync/reconnect.
4.2 Events and delivery
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
bothy.message.created |
Counter |
{message} |
bothy.message.origin (human, agent, app, import, system), bothy.message.kind |
api, worker |
bothy_message_created_total |
bothy.event.appended |
Counter |
{event} |
bothy.event.type |
api, worker |
bothy_event_appended_total |
bothy.db.event_seq.lock_wait |
Histogram |
s |
none |
api, worker: waiting for the counter row |
bothy_db_event_seq_lock_wait_seconds |
bothy.event.fanout.lag |
Gauge |
s |
none |
api: age of the oldest event that the log tail knows and the hub has not yet distributed (now minus the time at which it became known through NOTIFY or poll); 0 when everything is distributed. Reason: the age of the last distributed event would grow on a quiet instance and would stand still with a hung hub |
bothy_event_fanout_lag_seconds |
bothy.event.consumer.lag |
Gauge |
{event} |
bothy.consumer.name (push, webhooks) |
api, worker: head_seq - last_seq per row of event_consumer_cursors (head_seq = the highest seq in the log) |
bothy_event_consumer_lag |
bothy.event.consumer.pending_age |
Gauge |
s |
bothy.consumer.name |
api, worker: now minus occurred_at of the oldest event with seq > last_seq; 0 when the consumer is at the head. Reason: sync.event_retention is a time window |
bothy_event_consumer_pending_age_seconds |
bothy.delivery.duration |
Histogram |
s |
bothy.event.durability (durable, ephemeral), bothy.principal.kind |
api: occurred_at until the frame is written, live delivery only |
bothy_delivery_duration_seconds |
4.3 Agents and runs
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
bothy.run.active |
Gauge |
{run} |
bothy.agent.handle, bothy.run.state (queued, running, waiting_input) |
worker: query per interval |
bothy_run_active |
bothy.run.start.latency |
Histogram |
s |
bothy.agent.handle, bothy.outcome (started, failed, cancelled), bothy.billing_mode (personal, organization) |
api: queued_at until leaving queued |
bothy_run_start_latency_seconds |
bothy.run.duration |
Histogram |
s |
bothy.agent.handle, bothy.run.state (completed, failed, cancelled) |
api: started_at until the end |
bothy_run_duration_seconds |
bothy.run.finished |
Counter |
{run} |
bothy.agent.handle, bothy.run.state, bothy.reason (user_cancelled, access_revoked, cancel_timeout, agent_disabled, billing_mode_personal_not_holder, channel_read_only, billing_mode_mismatch, tool_class_owner_only, tool_class_mismatch, invalid_invocation_token, project_not_ready, agent_unavailable, interrupted, stale, runtime_error, adapter_error, runtime_not_authenticated, user_disabled: the state_reason of 09 section 5.2, any other value records as other; absent for completed) |
api |
bothy_run_finished_total |
bothy.run.steps |
Counter |
{step} |
bothy.agent.handle, bothy.step.kind |
api |
bothy_run_steps_total |
bothy.run.tokens |
Counter |
{token} |
bothy.agent.handle, bothy.token.type (input, output, cache_read, cache_write), gen_ai.provider.name, gen_ai.request.model |
api: from runs.metrics at the end of the run |
bothy_run_tokens_total |
bothy.run.cost |
Counter |
{currency_unit} |
bothy.agent.handle, bothy.billing_mode, bothy.currency, bothy.cost.estimate (true, false) |
api: from cost_entries |
bothy_run_cost_total |
bothy.agent.consumer.lag |
Gauge |
{event} |
bothy.agent.handle |
api: head_seq - acked_seq, maximum over consumers |
bothy_agent_consumer_lag |
bothy.agent.heartbeat.age |
Gauge |
s |
bothy.agent.handle |
api |
bothy_agent_heartbeat_age_seconds |
bothy.agent.billing_mode.mismatch_age |
Gauge |
s |
bothy.agent.handle |
api: seconds since the start of a mismatch between configured and reported mode according to the heartbeat (10, 8); 0 without a mismatch |
bothy_agent_billing_mode_mismatch_age_seconds |
bothy.agent.invocation.denied |
Counter |
{invocation} |
bothy.agent.handle, bothy.reason (billing_mode_personal_not_holder, tool_class_owner_only, project_not_ready, channel_read_only, user_disabled, agent_disabled, access_revoked) |
api: rejection before delivery (I2) |
bothy_agent_invocation_denied_total |
bothy.agent.billing_mode_mismatch |
Counter |
{run} |
bothy.agent.handle |
api: I3 |
bothy_agent_billing_mode_mismatch_total |
Tool names never appear on metrics (open set); the statistics of the tool calls come from
run_steps (admin-statistics.md).
4.3a Terminal
Without IDs of people, without host names, without contents (24-terminal.md,
section 10.4).
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
bothy.terminal.sessions.active |
UpDownCounter |
{session} |
bothy.terminal.state (opening, active, detached) |
api: the relay of each api process counts the sessions it holds with at least one connection (device stream or host data connection), in the state of the last committed terminal_session.changed, and stops counting at release; the sum over the api processes counts sessions with a connection on some node, a session without any connection (host offline, device gone) is not counted (M2-46, D-M2-46-13) |
bothy_terminal_sessions_active |
bothy.terminal.sessions.closed |
Counter |
{session} |
bothy.reason (codes from 24, section 10.3) |
api, worker: +1 per ended session after the commit of the end, in the process that committed it (a request, a subscriber, the relay or the job terminal.sweep); a rolled back end counts nothing (M2-46) |
bothy_terminal_sessions_closed_total |
bothy.terminal.relay.unpaired |
Counter |
{connection} |
bothy.terminal.side (client, host) |
api: one side found no counterpart within terminal.open_timeout (24, section 9) |
bothy_terminal_relay_unpaired_total |
bothy.terminal.hosts.connected |
UpDownCounter |
{host} |
none |
api: each api process counts its control connections that are usable for sessions (+1 when one becomes usable, -1 when it stops being usable); the sum over the api processes counts connected hosts (M2-46) |
bothy_terminal_hosts_connected |
4.3b Own measurement and live statistics
Operating values of the own measurement (25-visualization.md, section
3); the measured values themselves are in stats_minutes, not in the telemetry.
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
bothy.stats.flush.dropped |
Counter |
{key} |
bothy.reason (queue_full, db_error, invalid_key) |
api, worker: discarded counter keys of the own measurement |
bothy_stats_flush_dropped_total |
bothy.stats.flush.duration |
Histogram |
s |
bothy.outcome (ok, error) |
api, worker: one flush to stats_minutes |
bothy_stats_flush_duration_seconds |
bothy.stats.live.subscribers |
UpDownCounter |
{subscription} |
none |
api: subscriptions to stats.live on this node |
bothy_stats_live_subscribers |
4.4 Push, files, search, jobs
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
bothy.push.sent |
Counter |
{notification} |
bothy.push.provider, bothy.outcome (accepted, rejected_token, rejected_other, throttled, provider_error) |
worker: one per provider answer of the job notify.push_send, the outcome of its result class (13-push-notifications.md 7.3), recorded after the commit (M3-19) |
bothy_push_sent_total |
bothy.push.send.duration |
Histogram |
s |
bothy.push.provider |
worker: one provider request of the job notify.push_send, outside any transaction (M3-19) |
bothy_push_send_duration_seconds |
bothy.push.skipped |
Counter |
{notification} |
bothy.reason (no_access, preference, active_in_thread, no_registration, read, expired, no_provider) |
worker: one per recipient and hint the consumer push or a flush decides without a hint, and one per job notify.push_send that ends without sending; quiet hours make a hint quiet and never drop it (R-PN-05), so they are no reason (M3-19) |
bothy_push_skipped_total |
bothy.push.fetch |
Counter |
{notification} |
bothy.push.provider |
api: fetch by the Notification Service Extension or the Android handler; one per getPushPreview request whose preview token identified an active registration, with the registration's provider, whatever the answer after that (200, 404, or 401 from the session, proof or rights check); nothing for a malformed or unknown token, because no provider is known. The receipt t2 of 13-push-notifications.md section 9 and the numerator of the confirmation rate; the server keeps no hint record, so it measures no t2 minus t0 latency (M3-22) |
bothy_push_fetch_total |
bothy.push.registrations.active |
Gauge |
{registration} |
bothy.push.provider |
worker: the rows of push_registrations with disabled_at IS NULL per provider, observed at each collection (M3-19) |
bothy_push_registrations_active |
bothy.file.upload.size |
Histogram |
By |
none |
api |
bothy_file_upload_size_bytes |
bothy.file.scan |
Counter |
{file} |
bothy.outcome (clean, infected, error, skipped) |
worker |
bothy_file_scan_total |
bothy.file.scan.duration |
Histogram |
s |
bothy.outcome |
worker |
bothy_file_scan_duration_seconds |
bothy.file.scan.queue |
Gauge |
{file} |
none |
worker |
bothy_file_scan_queue |
bothy.file.purge.backlog |
Gauge |
{file} |
none |
worker: the files marked deleted whose purge is still due, recorded at the end of every run of files.cleanup_pending; nothing before the first run (M3-08) |
bothy_file_purge_backlog |
bothy.retention.deleted |
Counter |
{row} |
bothy.retention.class (security, content, transient, audit, cost) |
worker: the rows one run of a job retention.<class> removed, emptied or tombstoned, by class, recorded at the end of the run; never an ID or a table name (20-privacy.md 4.1, M7-05) |
bothy_retention_deleted_total |
bothy.search.duration |
Histogram |
s |
bothy.outcome (ok, client_error, timeout, server_error) |
api: one full-text search (search) from the start of the use case to its end; the prefix search is measured by http.server.request.duration (M3-14) |
bothy_search_duration_seconds |
bothy.job.duration |
Histogram |
s |
bothy.job.kind, bothy.outcome (completed, failed, cancelled, snoozed) |
worker |
bothy_job_duration_seconds |
bothy.job.queue |
Gauge |
{job} |
bothy.job.kind |
worker |
bothy_job_queue |
bothy.egress.blocked |
Counter |
{request} |
bothy.reason (url_not_allowed, scheme_not_allowed, host_not_allowed, address_blocked, redirect_not_allowed) |
api, worker: connection rejected by the egress client (19-security.md SEC-NET-5) |
bothy_egress_blocked_total |
4.5 Database
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
db.client.operation.duration |
Histogram |
s |
db.operation.name, db.collection.name |
api, worker: the query tracer of platform/pg. otelpgx v0.13.0 (verified 2026-10-18) records a metric of this name with the raw first word of a statement and pgx.operation.type, and without db.collection.name; the view of the metric registry drops every otelpgx instrument (M1-68) |
db_client_operation_duration_seconds |
bothy.db.pool.connections |
Gauge |
{connection} |
bothy.pool.state (idle, acquired, max) |
api, worker |
bothy_db_pool_connections |
postgresql.backends, postgresql.connection.max, postgresql.commits, postgresql.rollbacks, postgresql.db_size, postgresql.table.size, postgresql.deadlocks |
as from the receiver |
as from the receiver |
resource postgresql.database.name, postgresql.table.name (taken over as labels) |
collector, receiver postgresql (https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.162.0/receiver/postgresqlreceiver/documentation.md, retrieved 2026-10-10) [verified] |
postgresql_backends, postgresql_connection_max, postgresql_commits_total, postgresql_rollbacks_total, postgresql_db_size_bytes, postgresql_table_size_bytes, postgresql_deadlocks_total |
4.6 Backup, audit, telemetry
| Name |
Type |
Unit |
Attributes |
Source |
Prometheus |
bothy.backup.last_success.timestamp |
Gauge |
s |
bothy.backup.kind (physical, logical, objects) |
bothy-backupd |
bothy_backup_last_success_timestamp_seconds |
bothy.backup.wal.last_archived.timestamp |
Gauge |
s |
none |
bothy-backupd |
bothy_backup_wal_last_archived_timestamp_seconds |
bothy.backup.duration |
Gauge |
s |
bothy.backup.kind |
bothy-backupd (last run) |
bothy_backup_duration_seconds |
bothy.backup.size |
Gauge |
By |
bothy.backup.kind |
bothy-backupd |
bothy_backup_size_bytes |
bothy.backup.restore_test.last_success.timestamp |
Gauge |
s |
none |
bothy-backupd |
bothy_backup_restore_test_last_success_timestamp_seconds |
bothy.backup.restore_test.duration |
Gauge |
s |
none |
bothy-backupd (last run) |
bothy_backup_restore_test_duration_seconds |
bothy.backup.restore_test.failures |
Counter |
{check} |
bothy.reason (name of the check) |
bothy-backupd |
bothy_backup_restore_test_failures_total |
bothy.storage.volume.free |
Gauge |
By |
bothy.volume (pgdata, files, pgbackrest-repo, backup-scratch) |
bothy-backupd |
bothy_storage_volume_free_bytes |
bothy.storage.volume.size |
Gauge |
By |
bothy.volume |
bothy-backupd |
bothy_storage_volume_size_bytes |
bothy.audit.verify.failures |
Counter |
{run} |
none |
worker: periodic check of the hash chain |
bothy_audit_verify_failures_total |
bothy.audit.verify.last_success.timestamp |
Gauge |
s |
none |
worker |
bothy_audit_verify_last_success_timestamp_seconds |
bothy.telemetry.dropped |
Counter |
{item} |
bothy.signal (traces, metrics, logs) |
all processes |
bothy_telemetry_dropped_total |
bothy.secrets.refresh.failures |
Counter |
{refresh} |
none |
all processes: re-reading a secret fails, the old value stays (02-architecture.md 14.4) |
bothy_secrets_refresh_failures_total |
bothy.client_metrics.discarded |
Counter |
{entry} |
bothy.reason (unknown_name, kind_mismatch, invalid_bounds, invalid_value) |
api: entries of a submitClientMetrics batch the server discarded one by one (4.6a, ADR 0057, M4-31) |
bothy_client_metrics_discarded_total |
bothy-backupd sees the volumes pgdata and files read-only and measures only the free
space there.
4.6a App metrics
The apps send these metrics batched to POST /api/v1/client-metrics (ClientMetricsBatch,
15-client-architecture.md section 17, ADR 0057). The server
accepts only the names of this table and passes them on to the collector under bothy.client.*.
It discards, one by one, each entry with another name or with a kind that does not match the type
of the table (histogram, counter, gauge); the rest of the batch is valid, the response
stays 202. This way a newer app does not lose the whole batch against an older server. Attributes of all metrics: bothy.client.platform (ios, android,
macos, windows, web), bothy.client.density (comfortable, compact, touch),
bothy.client.theme (light, dark) and bothy.client.app_major (major version of the app, values
outside ^[0-9]{1,2}$ become other). No principal, no device. Histograms come with the
boundaries "Durations up to seconds" from 4.7; the server discards other boundaries.
| Name in the app |
Type |
Unit |
Further attributes |
Server name and Prometheus |
client.cold_start.duration |
Histogram |
s |
none |
bothy.client.cold_start.duration, bothy_client_cold_start_duration_seconds |
client.thread_open.duration |
Histogram |
s |
none |
bothy.client.thread_open.duration, bothy_client_thread_open_duration_seconds |
client.send_local.duration |
Histogram |
s |
none |
bothy.client.send_local.duration, bothy_client_send_local_duration_seconds |
client.frames |
Counter |
{frame} |
none |
bothy.client.frames, bothy_client_frames_total |
client.frames.over_budget |
Counter |
{frame} |
none |
bothy.client.frames.over_budget, bothy_client_frames_over_budget_total |
client.catchup.duration |
Histogram |
s |
none |
bothy.client.catchup.duration, bothy_client_catchup_duration_seconds |
client.catchup.events |
Counter |
{event} |
none |
bothy.client.catchup.events, bothy_client_catchup_events_total |
client.resync |
Counter |
{connection} |
none |
bothy.client.resync, bothy_client_resync_total |
client.reconnects |
Counter |
{connection} |
bothy.close.code (codes from 04 "Close codes", otherwise other) |
bothy.client.reconnects, bothy_client_reconnects_total |
client.outbox.depth |
Gauge |
{entry} |
none |
bothy.client.outbox.depth, bothy_client_outbox_depth |
client.push_display.duration |
Histogram |
s |
none |
bothy.client.push_display.duration, bothy_client_push_display_duration_seconds |
client.push.placeholder |
Counter |
{notification} |
none |
bothy.client.push.placeholder, bothy_client_push_placeholder_total |
4.7 Histogram boundaries
| Histograms |
Boundaries |
Durations up to seconds (http.server.request.duration, bothy.delivery.duration, bothy.gateway.connect.duration, bothy.run.start.latency, bothy.push.send.duration, bothy.db.event_seq.lock_wait, bothy.search.duration, bothy.stats.flush.duration, db.client.operation.duration, and the app histograms of 4.6a: bothy.client.cold_start.duration, bothy.client.thread_open.duration, bothy.client.send_local.duration, bothy.client.catchup.duration, bothy.client.push_display.duration) |
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 |
long durations (bothy.run.duration, bothy.gateway.catchup.duration, bothy.job.duration, bothy.file.scan.duration) |
1, 2.5, 5, 10, 30, 60, 120, 300, 600, 1800, 3600, 7200 |
sizes (bothy.file.upload.size) |
1024, 16384, 131072, 1048576, 8388608, 67108864, 536870912 |
The first row is the recommendation (ExplicitBucketBoundaries) of the Semantic Conventions for
http.server.request.duration
(https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-metrics.md,
retrieved 2026-10-10) [verified]. Every SLO threshold is one of these boundaries (R-OB-06); a check
compares le in the SLO files with this table.
5. Traces
5.1 Propagation
- W3C Trace Context (
traceparent, tracestate) on every HTTP request; apps create it
themselves because there is no Dart SDK (17-observability.md 9).
- At the public listener the server starts its own trace for every request and attaches
an incoming
traceparent only as a span link (otelhttp option for public
endpoints). Trace ID and sampling therefore come from the server. Only with
telemetry.traces.trust_remote_parent does an incoming context become the parent context; then
its trace ID and its sampling flag apply, and a client thereby chooses the correlation_id of its
requests itself.
correlation_id in event_log and audit_events is the trace ID of the triggering request
(16 bytes, written as a UUID, not a UUIDv7). Every request has a valid trace ID, even with
telemetry.enabled = false: the server then keeps an SDK tracer provider with
NeverSample and without an exporter. Jobs without a request context have none.
- Events to adapters carry
trace_context (W3C traceparent) of the triggering request if
it was recorded (envelope field trace_context, 04 section 2.2). The adapter makes it the
parent context of its invoke_agent span and passes it on to the runtime as the environment
variable TRACEPARENT. Claude Code reads TRACEPARENT in -p and in the Agent SDK
(https://code.claude.com/docs/en/monitoring-usage, retrieved 2026-10-10) [verified].
- The fan-out is not a child of the request but its own trace with a link to it.
5.2 Spans
| Span |
Kind |
Source |
Important attributes |
<METHOD> <http.route> |
SERVER |
otelhttp |
HTTP conventions, bothy.principal.kind, bothy.principal.pseudo_id, bothy.action, bothy.decision |
gateway.connect |
SERVER |
api |
bothy.outcome, bothy.close.code, bothy.principal.kind |
gateway.catchup |
INTERNAL |
api |
bothy.outcome, bothy.event.seq (start) |
event.fanout |
INTERNAL |
api, with links to the requests of the events |
bothy.event.seq (first number), count |
agent.dispatch |
PRODUCER |
api |
bothy.run.id, bothy.agent.handle, bothy.decision |
push.send |
CLIENT |
worker |
bothy.push.provider, bothy.outcome |
webhook.deliver |
CLIENT |
worker |
server.address (host only), http.response.status_code (when a response arrived), bothy.outcome (succeeded, throttled, gone, failed, blocked; M2-26) |
file.scan |
CLIENT |
worker |
bothy.file.id, bothy.outcome |
job <bothy.job.kind> |
CONSUMER |
worker (River) |
bothy.job.kind, bothy.outcome (completed, failed, cancelled, snoozed) |
txn.subscribe <Subscriber> |
INTERNAL |
api, worker: per in-tx subscriber (02-architecture.md 8.3) |
bothy.outcome (ok, veto) |
| Database |
CLIENT |
otelpgx |
db.system.name, db.namespace, db.operation.name, db.query.text (with placeholders, never parameters) |
invoke_agent <gen_ai.agent.name> |
CLIENT |
adapter (SDK) |
gen_ai.* from section 6, bothy.run.id |
execute_tool <gen_ai.tool.name> |
INTERNAL |
adapter (SDK) |
gen_ai.tool.name, bothy.outcome |
A span never lives longer than one operation. A WebSocket connection is not a span.
5.3 Attributes in spans and logs
This list equals the allowlist in infra/otel/collector.yaml
(Bothy part); make ops-check keeps both equal (check collector-allowlist).
| Attribute |
Values |
Note |
bothy.action |
action ID from 05-actions.md |
|
bothy.agent.handle |
handle of the agent |
assigned by the owner, not a person |
bothy.agent.adapter_kind |
agents.adapter_kind |
|
bothy.backup.kind |
physical, logical, objects, restore_test |
|
bothy.billing_mode |
personal, organization |
|
bothy.channel.id, bothy.space.id, bothy.thread.id, bothy.message.id, bothy.run.id, bothy.file.id |
UUID |
never on metrics |
bothy.close.code |
close code from 04-sync-protocol.md 6.4 |
|
bothy.consumer.name |
push, webhooks |
name of the worker consumer (03-data-model.md event_consumer_cursors) |
bothy.correlation_id |
UUID |
only in logs; in spans it is the trace ID |
bothy.csp.blocked_origin |
origin (scheme, host, port) of the blocked URL of a CSP violation report, or one of inline, eval, wasm-eval, data, blob, trusted-types-policy, trusted-types-sink, other; never a path, a query or the document URL |
only in the log csp violation reported, never on metrics (M6-03) |
bothy.csp.directive |
effective directive of a CSP violation report of the console from the fixed list of the handler (script-src-elem, style-src-elem, trusted-types, ...), else other |
log csp violation reported (info) of submitCspReport and the metric bothy.console.csp_violations (M6-03) |
bothy.csp.disposition |
enforce, report, other |
like bothy.csp.directive |
bothy.decision |
decision from 05-actions.md 2.5 |
|
bothy.egress.purpose |
purpose of an egress client from the code (oidc, webhook, ...) |
only in logs of rejected connections (19-security.md SEC-NET-5) |
bothy.event.durability |
durable, ephemeral |
|
bothy.event.seq |
global sequence |
never on metrics |
bothy.event.type |
event type from 05-actions.md 6 |
|
bothy.job.kind |
job kind from the code |
|
bothy.outcome |
fixed list per signal |
|
bothy.principal.kind |
user, agent, app, anonymous |
|
bothy.principal.pseudo_id |
HMAC-SHA-256 of the principal ID, truncated |
privacy.md 3 |
bothy.push.provider |
apns, fcm, unifiedpush, wns |
|
bothy.ratelimit.class |
class from 05-actions.md 3 |
|
bothy.reason |
fixed list per signal |
never free text |
bothy.run.state |
run state |
|
bothy.server.role |
api, worker, all |
resource |
bothy.signing_key.purpose |
access_token, invocation, webhook_out, audit_checkpoint |
only in logs of key transitions (19-security.md 10) |
bothy.signing_key.kid |
kid of a server key (thumbprint per RFC 7638, public) |
only in logs of key transitions, never on metrics |
bothy.signing_key.from_state, bothy.signing_key.to_state |
none, next, active, retiring, retired |
only in logs of key transitions |
bothy.step.kind |
tool_call, status, subagent, workflow, background_job |
|
bothy.terminal.side |
client, host |
side of a connection in the relay (24-terminal.md section 9); on bothy.terminal.relay.unpaired and the log records of the relay (M2-44) |
bothy.version.own, bothy.version.required |
SemVer of the own server version and of instance.min_server_version |
only in the start line of the schema check (02-architecture.md 15.1, step 5) |
bothy.webhook_subscription.id |
UUID of an outgoing webhook subscription; never on metrics |
logs webhook delivery failed (info) and webhook subscription state changed (warn) of the apps module (M2-26), with bothy.event.seq, bothy.event.type, bothy.reason (the last_error code or the cause) and server.address |
6. GenAI conventions (encapsulated)
- The GenAI conventions live in the repository
open-telemetry/semantic-conventions-genai and
are entirely in the status "Development"; for Anthropic, gen_ai.provider.name =
"anthropic" applies (https://github.com/open-telemetry/semantic-conventions-genai, retrieved
2026-10-10) [verified].
- Per code base there is exactly one package with all
gen_ai.* keys: in the server
internal/platform/otel/genai (02, PLAN W8), in the SDKs a module telemetry.genai. The rest
of the code uses only its constants. The package names the state of the convention: repository,
status and retrieval date, plus the commit as soon as a ticket with network access has read it. The
commit was not recorded during planning [open].
- A snapshot test records the keys in use; a new convention changes package and
snapshot in one change, and allowlist and dashboards in the same change.
- Never sent:
gen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions,
gen_ai.tool.definitions, arguments and results of tools. The collector also deletes
them.
7. Logs
| Field |
Rule |
| Time, level |
from the log record |
Message (body) |
constant English text from the code, never composed from input; a linter enforces constant messages |
trace_id, span_id |
from the context |
| Attributes |
only from 5.3 |
| Error |
error.type (class or code), never the error message of a third-party library with input values |
Depending on telemetry.logs.exporter, the server writes logs as OTLP to the collector, as JSON to
stdout or both. In the Compose stack the log driver limits stdout (BOTHY_LOG_MAX_SIZE,
BOTHY_LOG_MAX_FILE). Logs in Go are "Release candidate" in the OTel SDK, in Python "Development"
(https://opentelemetry.io/docs/languages/go/ and /python/, retrieved 2026-10-10) [verified]; therefore
stdout stays as a fallback.
8. End-to-end examples
| Flow |
Signals |
| Send a message |
span POST /api/v1/threads/{id}/messages with DB spans; bothy.event.appended, bothy.db.event_seq.lock_wait; span event.fanout with link; bothy.delivery.duration per recipient |
| Agent run |
as above, plus agent.dispatch, at the adapter invoke_agent (child of trace_context), runtime spans below it; bothy.run.start.latency, bothy.run.steps, bothy.run.duration, bothy.run.tokens, bothy.run.cost |
| Push |
span push.send in the trace of the fan-out; bothy.push.sent; later bothy.push.fetch when the app fetches |
| Reconnect |
span gateway.connect, gateway.catchup; bothy.gateway.connect.duration, bothy.gateway.catchup.duration, if needed bothy.gateway.resync |