Task · SFL-0065

obs: optional OTel traces over the already-shipped OTLP exporter for per-stage pipeline timelines

Status
To Do
Labels
followup, phase-3, roadmap
Milestone
Feature roadmap — operator ergonomics & platform capabilities
Updated
2026-08-14

Description

What

sf2loki is OTLP-push-native but metrics-only. opentelemetry-sdk and opentelemetry-exporter-otlp-proto-http are hard runtime dependencies (pyproject.toml:24-25), yet the only OTel usage in src/ is the meter wiring in src/sf2loki/obs/metrics.py:

No module imports opentelemetry.trace. TelemetryConfig (config.py:1200-1255) has no traces fields, and config.py:1209-1216 documents endpoint as the metrics URL (https://otlp-gateway-<zone>.grafana.net/otlp/v1/metrics). src/sf2loki/obs/ contains only metrics.py, logging.py, health.py, limits_poller.py. docs/observability/ contains only metrics.md, dashboards.md, alerts.md.

The same exporter distribution already installed ships opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter, and opentelemetry.sdk.trace ships TracerProvider / BatchSpanProcessor (verified importable in the repo venv at opentelemetry 1.43.0). The Grafana Cloud gateway exposes a sibling /otlp/v1/traces path that accepts the identical basic-auth header telemetry_headers() already computes (config.py:1571-1589). So traces are a wiring change with zero new dependencies.

Long multi-step operations are currently observable only as aggregates, with no per-operation breakdown:

Why it matters

sf2loki_ingest_lag_seconds (obs/metrics.py:341-360) spikes for one event type. The dashboards show the lag but cannot attribute it: Salesforce list latency, blob download, CSV parse, queue wait, or Loki push retries. Answering that today means reading interleaved JSON debug logs across concurrent workers and reconstructing a timeline by hand. Four coarse spans per cycle turn that into one trace view.

Value is convenience-grade, not correctness-grade — hence low severity. The feature must stay strictly opt-in and default off so existing deployments are byte-identical in behaviour.

Proposed approach

Config (config.py, TelemetryConfig at :1200):

Validate in the same place as the existing telemetry credential check (config.py:1557-1567): traces_enabled without enabled is a config error. config.py changes require just gen-config (regenerates config.example.yaml + docs/config-reference.md; tests/test_config_artifacts_drift.py is the CI gate).

Wiring — new module src/sf2loki/obs/tracing.py rather than growing metrics.py:

Spans — coarse only, no per-event spans:

span site
elf.poll_cycle (attrs: event type count, org) sources/eventlogfile_source.py:283-302
elf.event_type → child elf.download, elf.parse sources/eventlogfile_source.py:490
eventlog_objects.drain_segment sources/eventlog_objects_source.py DESC drain loop
loki.push with one span event per retry attempt (attempt number, status, Retry-After) sinks/loki/sink.py:219-231
checkpoint.commit_many state/ store flush path

Two constraints that must shape the implementation:

  1. Async-generator context. events() at sources/eventlogfile_source.py:283 is an async generator that yields entries mid-cycle. Wrapping a with tracer.start_as_current_span(...) around a yield attaches OTel context that survives the generator’s suspension and leaks into the consumer’s task. Use explicit span = tracer.start_span(...) / span.end() in a try/finally for any span that spans a yield.
  2. The pipeline is decoupled, so there is no end-to-end trace. Sources hand entries to per-lane queues and a separate worker task pushes them; Batch (model.py:66) carries no trace context and must not grow one (that would mean per-event context propagation and per-event spans). Source-side spans and loki.push spans are therefore separate traces, joined by resource + attributes, not by parent/child. Document this explicitly so nobody later “fixes” it by threading context through the queue.

Span attributes are metadata only — event type, org id, file id, row/byte/entry counts, attempt numbers, status codes. Never row content, field values, or usernames; the redaction/filter rules apply to event bodies, and spans must not become a bypass.

Doctor. Extend _check_telemetry (doctor.py:429) or add a sibling check that POSTs an empty protobuf body to the resolved traces endpoint when traces_enabled, reusing the existing empty-export pattern and the same 401/403 credential guidance.

Docs. New docs/observability/traces.md (what spans exist, the two constraints above, Grafana Cloud + local Alloy endpoint examples, sampling and cost notes), registered in the zensical.toml nav next to observability/metrics.md (zensical.toml:35-37).


Imported from GitHub issue #149 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 149)' archive/issues-dump.json).

Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).

Acceptance Criteria

Definition of Done

References

View the source file on GitHub