internal/obs (logging, metrics, tracing), internal/usage (billing usage collection), and internal/common/clientid (request attribution ID propagation). See the Architecture overview for where they sit in the system.
Responsibilities and boundaries
internal/obsis responsible for: installing the process-levelsloghandler, automatically injectingtask_id/call_id/instance_idfrom ctx into logs, declaring all Prometheus metrics and providing theRecord*functions, initializing the OpenTelemetry tracer, and starting the/metricsHTTP endpoint.internal/usageis responsible for: accumulating, perclient_id, the executor’s effective CPU time at the moment each task converges, and periodically freezing it into records written to the Kafka topicchaintable-usage.internal/common/clientidis responsible for: reading and writingclient-idand the compatibility headerx-instance-idat gRPC/HTTP boundaries, and carrying the full ID in ctx inside the process.- Not responsible for: the log aggregation stack, alerting rules, the Prometheus server, or the OTLP collector. These all live on the deployment side.
- Invariant:
core/packages (Sans-IO) do not importinternal/obs; only adapter andcmd/code writes logs, records metrics, or opens spans. See Design principles and code conventions. - Invariant: the log
instance_id, the chaintable-logtrace_id, and the Prometheusinstance_idlabel all useclientid.LogKeyto strip theinstance:prefix; usage messages and all downstream headers keep the full original value. - Invariant: Prometheus labels are limited to closed enumerations (
status/phase/backend/kind, etc.) plus the two high-cardinality dimensionsinstance_id/function_id, which are protected by the TTL reaper. Never use task_id, call_id, URLs, or error text as labels. - Invariant: metrics are registered to the default registry via
promauto; every vec that carries aninstance_idorfunction_idlabel must also be listed ininstanceIDVecs/functionIDVecsininternal/obs/metrics_reaper.go, otherwiseTestReaperVecListsMatchDeclarationsfails.
Code location
Core types and interfaces
Key signatures (copied from the code):
Data flow / execution flow
A request’s attribution identifier enters from gRPC metadata and flows along ctx to four outlets: logs, spans, metrics, and usage:1
Boundary normalization
SubmitTask in internal/worker/adapters/grpc_server.go, Invoke/DebugInvoke in internal/syncinvoker/adapters/grpc_server.go, and internal/coordinator/adapters/grpc_server.go all begin with ctx = clientid.WithContext(ctx, clientid.FromIncomingGRPC(ctx)). client-id takes priority, falling back to x-instance-id when it is missing; if both are missing the result is unknown.2
In-process propagation
The Worker rebinds with
clientid.WithContext(ctx, rt.instanceID) inside the task runtime (internal/worker/adapters/orchestrator_dispatch.go, orchestrator_phases.go) so that asynchronous goroutines and IO callbacks do not lose the ID. obs.WithInstanceID is just an alias for clientid.WithContext; it does not store a second copy of the value.3
Log and metric outlets
ContextHandler.Handle writes instance_id after sanitizing it with clientid.LogKey; metricInstanceID applies the same sanitization to the Prometheus label and refreshes the series’ last-seen timestamp. The chlog bridge uses LogInstanceIDFromCtx as the trace_id.4
Usage and downstream
When a task converges,
orchestrator_phases.go calls o.usageRecorder.Record(taskInstanceID, taskUsage), passing the full ID. The internal/sdk/blockdb, logicaltypes, noderpc, and meta adapters use IntoOutgoingGRPC/IntoHTTP to double-write both headers to downstream services.Logging
Format and fields
- Each log entry is one line of JSON written to stderr (
internal/obs/logger.go).timeis millisecond-precision UTC (2006-01-02T15:04:05.000Z07:00), aligned with the Python executor so that Go/Python logs from the same process tree can be parsed as a single interleaved stream. - Correlation fields are injected automatically from ctx:
task_id,call_id,executor_id,attempt_seq(emitted once bound, even when it is 0), andinstance_id. - Two rules (from
docs/specs/logging.md§4.1): functions that have a ctx useslog.InfoContext(ctx, ...); goroutine closures without a ctx must receive a*slog.Loggerthat the caller pre-bound withslog.Default().With(obs.AttrCallID, id). - Field names always go through the
obs.Attr*constants. The error field iserr. obs.Init()takes no arguments;obs.BindProcesshas no call sites incmd/orapp, andservice/worker_addronly appear in a few logs that pass them explicitly.
Structured log types (the type field)
The obs.LogType* constants are the complete set of legal values for the type field. The most important ones from a user’s perspective:
The
LogTypeTaskPhaseFinished, LogTypeCallFailed, and LogTypeIOError constants still exist but are no longer emitted separately; their content is folded into task_finished.
chaintable-log bridge
OnceCHAINTABLE_LOG_BROKERS is set, obs.InitChlog replaces the inner handler of ContextHandler with chlogBridgeHandler: every record is still written to stderr and is simultaneously forwarded to Kafka (default topic instance-logs), mapping type → chlog WithType, the remaining attrs → the data payload, and the instance id from ctx → trace_id. Records without an instance id (coordinator background logs, etc.) are silently dropped by the SDK.
Metrics
Naming and registration
- Namespace
blockx, snake_case, counters end in_total, histograms carry a unit suffix (_milliseconds,_seconds_total,_bytes). - All declarations are package-level
promauto.New*Vec(...), exposed to adapters through theobs.Record*functions; adapters do not holdprometheus.*objects directly. IO adaptive rate limiting is the exception: it uses a customprometheus.Collectorthat pulls snapshots. - Task-dimension metrics are observed once when the task converges: avg/p90/p99 are computed in-process by the CKMS accumulators in
internal/io/core/stats/and observed once each with astatlabel. Execution-count-weighted averages are obtained by dividing the two counters*_duration_seconds_total / *_duration_samples_total.
Metric families and definition files
internal/worker/adapters/call_metrics_test.go, executor_metrics_test.go, and task_barrier_metrics_test.go are tests for these record points, not definition files. internal/worker/adapters/resource_metrics.go is the CPU/memory sampler used by heartbeats (WorkerResourceSampler); it does not export Prometheus metrics.
Most important metrics
Tracing
obs.InitTracing(ctx, service)always sets the W3C TraceContext propagator. Exporter selection:BLOCKX_OTEL_SPAN_FILEtakes priority (JSON-lines appended to a file, shared across processes) →OTEL_EXPORTER_OTLP_ENDPOINT(OTLP/gRPC, insecure) → noop when neither is set.obs.TracingEnabled()lets hot paths skiptracer.Start.- Sampling:
AlwaysSampleby default; a value such asOTEL_TRACES_SAMPLER_ARG=0.1switches toParentBased(TraceIDRatioBased). Production must set this explicitly. - The service name is passed in by the entry point:
blockx-worker,blockx-bundle-worker,blockx-coordinator,blockx-bundle-coordinator,blockx-syncinvoker(Profile.Serviceincmd/*/main.go); on the Python side it isblockx-executor. - Automatic spans: Coordinator→Worker gRPC uses
otelgrpc.NewClientHandler()/NewServerHandler()(internal/coordinator/adapters/worker_rpc.go,internal/worker/app/app.go). - Manual spans: Worker↔Executor UDS uses the
udsTracerininternal/worker/adapters/executor/send.go(tracer namegithub.com/Chaintable/blockx/worker/uds), with span namesuds.send.<MsgType>/uds.recv.<MsgType>/worker.io.dispatch; trace context crosses UDS via theMessageEnvelope.traceContextfield. The Sync Invoker usesobs.Tracer("blockx-syncinvoker"). BLOCKX_OTEL_UDS_EVENTS=1enables the fine-grained UDS pipeline events fromobs.AddUDSEvent/AddUDSEventAt; for perf debugging only.analyze_spans.pyreads the span file, normalizes the two JSON formats produced by Go’sstdouttraceand Python’sConsoleSpanExporter, and prints the span tree and event offsets per trace. Typical usage:
docs/specs/tracing.md §4 in the blockx repo for the complete span/event list.
Usage collection
- What is collected: when each task converges,
taskRuntime.snapshotUsage()(internal/worker/adapters/task_runtime.go) converts the effective execution time reported by the executor (callEffectiveSum, in milliseconds) intousage.Sample{CPUTimeMicros}. This excludes executor queueing, IO waits, and the Go-side CPU spent in the Builder/Result Handler. - Attribution:
Collector.Record(clientID, sample)accumulates intoactiveby fullclient_id; an empty ID is recorded asunknown; samples withCPUTimeMicros<=0are discarded. - Where it goes: by default
Flushruns every 5s, freezingactiveinto[]usage.Record(idUUID,client_id,service,resource_type,usagein milliseconds,timestampat freeze time) and writing synchronously to the topicchaintable-usageviaKafkaSink.Publish(key isclient_id,RequiredAcks=RequireAll,MaxAttempts=1). On failure the records stay inpendingand are retried in the next round with the sameid; consumers deduplicate byid. - Service enumeration:
usage.ServiceBlockXWorker = "blockx_worker"(cmd/worker),usage.ServiceBlockXBundleWorker = "blockx_bundle_worker"(cmd/bundle_worker);resource_typeis fixed tousage.ResourceTypeCompute = "compute". These are bound byProfile.UsageServiceand cannot be changed through configuration. The Sync Invoker does not create a collector. - Fail-closed: unless explicitly
Disabled,NewKafkaCollectorrequires non-empty brokers and a topic with partitions; if the probe fails, the process refuses to start.
Client id propagation
State and lifecycle
- usage Collector:
Startlaunches the ticker goroutine;Closesets the closing barrier (after whichRecordis rejected), stops the ticker, retries the finalFlushevery 200ms within the shutdown ctx provided by the caller, and then callssink.Close. An error returned fromCloseis treated as a billing-critical error and the Worker exits non-zero. - Prometheus series TTL: once an
instance_id/function_idlabel value has been idle for longer thanBLOCKX_METRICS_SERIES_TTL_MS(default 24h), the reaper (default every 10min,BLOCKX_METRICS_SERIES_SWEEP_INTERVAL_MS) deletes it withDeletePartialMatch, at most 256 values per axis per round. An explicit non-positive value disables it. The reaper starts withStartPrometheusEndpointand runs even when no scrape address is configured. - Table read/write log deduplication: 10-minute window for
table_read, 1-minute window fortable_write, 64 shards with at most 2048 keys per shard. - tracing / UDS events switches: environment variables are read once at process startup; changing them at runtime has no effect.
Monitoring endpoints
Worker and Coordinator currently have no HTTP health probes and no gRPC health service.
Configuration
Everything is configured through environment variables (the Worker also accepts the corresponding fields in its JSON config file; seePrometheus/Chlog/Usage in internal/worker/app/config.go):
For the complete deployment-side variable list, see
docs/deploy.md §9 in the blockx repo and the Deployment overview on this site.
Performance analysis
- Methodology:
docs/specs/perf-methodology.mdin the blockx repo (the model → measure → compare → attribute → decide loop); reproduction guide indocs/specs/perf-test-how-to-v2.md; conclusions inperf-test-report-v2.md. - Test cases:
e2e/perf/is organized by S0–S5 stage prefixes (s0_framework_*,s1_builder_*,s2_call_count_*,s3_cpu_*,s4_io_*,s5_plugin_*), indexed ine2e/perf/README.md. Artifacts land ine2e/perf/_artifacts/<TestName>/spans.jsonlandpyspy/. - How to run:
./test.sh -run TestPerf_XXXruns inside a cgroup (default 4 vCPU / 8 GB); runninggo test ./e2e/perf/...directly is the unconstrained baseline. Subtests withwith_tracesetBLOCKX_OTEL_SPAN_FILEautomatically. - Online load testing:
go run ./cmd/perf -coordinator host:port -n 100 -c 4 -mode full|meta|reserve, which outputs P50/P90/P99. - Process-level attribution: write
WORKER_CPU_PROFILE/WORKER_TRACEto a file, or capture/debug/pprof/profile.
Extension points
- Adding a log field or
type: add anAttr*/LogType*constant ininternal/obs/logger.go; if the type is user-visible, updatedocs/specs/blockx-log-types.mdas well. - Adding a Prometheus metric: declare it with
promautoininternal/obs/metrics.go(or themetrics_*.goof the corresponding subsystem) and add aRecord*function; labels may only be closed enumerations plusinstance_id/function_id; vecs carrying either of these two labels must be added toinstanceIDVecs/functionIDVecsinmetrics_reaper.go.instance_idvalues must go throughmetricInstanceID. - Adding adaptive metrics for a new IO backend: register through
internal/io/adaptive/observed, which callsobs.RegisterIOAdaptiveMetrics(backend, provider). - Adding spans: only in the adapter layer; first consider whether an event (
obs.AddUDSEvent) can express it; across goroutines, use a child span rather than adding events to a parent span that has already ended. - Adding an entry point that needs billing: pass a new
usage.Service*constant viaProfile.UsageServiceincmd/<entry>/main.go;service/resource_typemay not be overridden from configuration. - Adding a downstream adapter: call
clientid.IntoOutgoingGRPC(ctx)orclientid.IntoHTTP(req)before sending; do not read the metadata yourself.
Testing
internal/obs/handler_test.go: slogtest compliance ofContextHandler, field injection,WithAttrs/WithGrouppreserving injection,LogKeysanitization.internal/obs/chlog_bridge_test.go: bridge double-write,typekept out of data, group prefixes,trace_idsanitization.internal/obs/metrics_test.go,metrics_function_test.go,stream_metrics_test.go,metrics_syncinvoke_test.go,metrics_io_adaptive_test.go: the labels and values written by eachRecord*function.internal/obs/metrics_reaper_test.go,metrics_reaper_consistency_test.go: TTL reaper behavior;TestReaperVecListsMatchDeclarationsparses the source to ensure every vec withinstance_id/function_idis registered in the reaper lists.internal/obs/table_dedupe_test.go: deduplication windows and shard caps.internal/usage/collector_test.go,metrics_test.go: closing barrier, Kafka topic probing, publish failure metrics.internal/common/clientid/client_id_test.go: header priority, double-write,LogKey.- Record-point tests live in
internal/worker/adapters/call_metrics_test.go,executor_metrics_test.go, andtask_barrier_metrics_test.go; process-level log schema assertions are incmd/worker/worker_process_logging_test.go.
Related docs
Specs in the blockx repo:docs/specs/logging.md— log schema, field table, Go/Python call contract.docs/specs/blockx-log-types.md— user-facing list oftypelogs,task_finishedfield definitions, how to derive metrics from logs.docs/specs/tracing.md— how to enable it, complete span/event list,analyze_spans.pyusage, troubleshooting table.docs/specs/client-id-propagation.md— header priority, outbound double-write, log/monitoring sanitization rules.docs/specs/2026-07-13-blockx-usage-collection.md— usage message format, CPU accounting definition, flush and failure semantics.docs/specs/perf-methodology.md,docs/specs/perf-test-how-to-v2.md— performance testing methodology and reproduction.docs/deploy.md§6, §9 — deployment-side observability configuration and environment variables.