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:
obs/metrics.py:269-286builds anOTLPMetricExporter+PeriodicExportingMetricReaderwhenservice.telemetry.enabledis set.obs/metrics.py:288-297builds theResource(service.name=sf2loki,service.version, plustelemetry.resource_attributes) and theMeterProvider.obs/metrics.py:626-632exposesforce_flush()/shutdown();app.py:1220callsshutdown()on graceful exit.
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:
- EventLogFile poll cycle:
sources/eventlogfile_source.py:283-302times the whole cycle into thesf2loki_eventlogfile_cycle_secondsgauge (obs/metrics.py:570-575); the internal stages (_resolve_event_typeslisting at:397,_process_cyclefan-out at:314,_process_event_typedownload + CSV parse at:490) have no timing at all. - Loki push:
sf2loki_loki_push_duration_seconds(obs/metrics.py:321-326) covers the entire tenacity retry envelope atsinks/loki/sink.py:219-231, so a 3-attempt push with backoff is indistinguishable from one slow attempt. - Big-object DESC drain (
sources/eventlog_objects_source.py) and checkpoint flush (state/) are similarly single-number.
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):
traces_enabled: bool = False— “Push coarse pipeline spans via OTLP/HTTP. Requiresenabledfor credential/resource reuse.”traces_endpoint: str = ""— full OTLP/HTTP traces URL; when blank andendpointends in/v1/metrics, derive it by replacing that suffix with/v1/traces; when blank and no such suffix, fail validation with an explicit message rather than guessing.trace_sample_ratio: float = 1.0— wired toTraceIdRatioBased; safe at 1.0 because span volume is bounded by cycles, not events.
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:
- Factor the resource construction out of
obs/metrics.py:288-297into a sharedbuild_resource(version, telemetry) -> Resourceso both providers carry byte-identical resource identity. class Tracing: owns aTracerProvider+BatchSpanProcessor(OTLPSpanExporter(endpoint=..., headers=...))whentraces_enabled, otherwise a provider with no processor (spans become cheap no-ops). Exposetracer(name),force_flush(),shutdown().- Do not set the global tracer provider. Follow the injected-dependency pattern
Metricsalready uses so tests can build isolated instances; construct it at the composition root next toMetrics(app.py:913-916, reusingtelemetry_headers(cfg.service.telemetry, cfg.sink.loki)) and callshutdown()alongsideself._metrics.shutdown()atapp.py:1220. backfill.py:737anddoctor.py:362,883build a bareMetrics(); giveTracingthe same zero-arg disabled default so those paths need no change.
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:
- Async-generator context.
events()atsources/eventlogfile_source.py:283is an async generator that yields entries mid-cycle. Wrapping awith tracer.start_as_current_span(...)around ayieldattaches OTel context that survives the generator’s suspension and leaks into the consumer’s task. Use explicitspan = tracer.start_span(...)/span.end()in atry/finallyfor any span that spans ayield. - 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 andloki.pushspans 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
- #1
service.telemetry.traces_enabled,traces_endpoint,trace_sample_ratioadded toTelemetryConfig(config.py:1200), all defaulting to traces-off. - #2
traces_enabled: truewithenabled: falseraises aConfigErrornaming both fields. - #3 Blank
traces_endpointderives/v1/tracesfrom anendpointending in/v1/metrics; a non-matchingendpointwith blanktraces_endpointis a config error, not a silent guess. - #4
just gen-configre-run;tests/test_config_artifacts_drift.pygreen. - #5
src/sf2loki/obs/tracing.pyprovidesTracingwithtracer(),force_flush(),shutdown(); no global tracer provider is installed. - #6
build_resource()shared byMeterProviderandTracerProvider; a test asserts both providers report identical resource attributes. - #7
Tracingconstructed atapp.py:913-916and shut down besideself._metrics.shutdown()atapp.py:1220;backfill.pyanddoctor.pyneed no signature changes. - #8 Spans emitted for the ELF poll cycle, per-event-type download/parse, big-object drain segment, Loki push (retry attempts as span events), and checkpoint flush.
- #9
docs/observability/traces.mdadded and registered inzensical.tomlnav. - #10
tests/obs/test_tracing.py: with traces disabled (default), no span processor is attached andtracer().start_span(...)records nothing exportable; with traces enabled against anInMemorySpanExporter, the expected span names/attributes appear. - #11
tests/obs/test_tracing.py: endpoint-derivation table test (/otlp/v1/metrics→/otlp/v1/traces, explicittraces_endpointwins, non-matching endpoint errors). - #12
tests/sinks/: a Loki push forced through two transient failures produces oneloki.pushspan with three retry-attempt span events carrying attempt number and status. - #13
tests/sources/: an ELF cycle over a fake client produceself.poll_cyclewithelf.event_type/elf.download/elf.parsechildren, and a regression test asserts no OTel context remains attached in the consumer task after the generator yields (guards the async-generator leak). - #14 A test asserts no span attribute carries event-row content (spans built from a fixture row expose only counts/ids/types).
- #15
just gategreen (ruff +mypy --strict+ pytest).
Definition of Done
- #1 just gate is green (ruff check + ruff format –check + mypy src + pytest) — run it, don’t assert it
- #2 just gen-config run and its output committed, if config.py changed (CI drift gate fails otherwise)
- #3 committed straight to main with a conventional-commit message, and pushed