Pick one change and ask a specific question: how long did it take to get from its Jira ticket to production, and where did the time go?
Most teams can’t answer that with one number they trust. Not because the tools are disconnected — Jira and GitLab integrate fine — but because no single tool measures the whole path.
This is why that happens, and a simple way to fix it. I built this for my bachelor’s thesis. Everything ran locally on one machine with Docker Compose, so treat it as a validated proof of concept, not a production system.
The problem
A delivery toolchain is split across systems on purpose. Jira tracks the work. GitLab holds the code and runs CI/CD. Jaeger and Prometheus watch production. Each does its job well.
They can integrate, too. Jira’s development panel will show an issue’s branches, commits, merge requests, and deployments. That linking is real and useful. But linking records is not the same as measuring a path.
Each tool still calculates its delivery metrics inside its own boundary, with its own definitions. GitLab computes lead time from merge requests and deployments. Jira measures cycle time from workflow states. Neither covers the full path from issue to production, and the two numbers rarely agree. There’s no single end-to-end record you can point to and ask: for this change, how long, and where did the time go?
This shows up in two of the four DORA metrics. Deployment Frequency is straightforward — you count deployments. Lead Time for Changes is not. It requires linking events that live in different tools and measuring the time between them. Counting is easy. Measuring across boundaries is where it falls apart.
Why tracing doesn’t solve it on its own
What’s missing isn’t a link between records — it’s a single timeline. Distributed tracing is built for exactly that: instead of associating events after the fact, it records them on one trace as they happen, so the duration between any two points is just arithmetic.
It works by context propagation: when one service calls another, it passes a trace ID in the request headers. The next service reads that ID, attaches its work to the same trace, and passes it along. The spans join into one record.
This works for synchronous request/response traffic. It stops working when systems communicate asynchronously, which is how dev tools actually talk to each other.
Moving a Jira ticket fires a webhook. Pushing code fires a webhook. A finished pipeline fires another. A webhook is a one-way HTTP callback, and it carries no trace context. There are no headers to propagate, so the chain breaks at every tool boundary.
Standard OpenTelemetry then does the correct thing for the wrong situation: it generates a fresh random trace ID for each webhook. One workflow becomes a set of disconnected single-span traces. In my runs, that averaged 13.3 separate traces per workflow. Nothing links the commit to the deployment, so Lead Time can’t be calculated. The system records that events happened without recording that they were related.
The idea
If I can’t pass a shared ID between tools, I can have each tool independently arrive at the same one.
That only works if the events share something. They do: the Jira issue key. It’s in the Jira event, in the branch name (TE-123-fix-login), and in the commit message (TE-123: fix login bug). It’s the one identifier that stays constant across a change’s whole life.
So instead of a random trace ID, I derive a deterministic one from that key:
import hashlib
def trace_id_from_business_key(jira_key: str) -> str:
normalized = jira_key.strip().lower() # "TE-123" -> "te-123"
return hashlib.md5(normalized.encode()).hexdigest() # 128-bit, hex-encoded
A 128-bit hash is the width of a W3C trace ID. So any event that references TE-123 produces the same trace ID and joins the same trace, even when the events happen days apart on different infrastructure.
OpenTelemetry supports this cleanly because it separates its API from its SDK. You provide a custom IdGenerator and set the trace ID before the SDK reaches for its default random one. The span ID stays random, so each event is still unique within the trace. Only the trace ID becomes deterministic.
The system
The logic lives in a small FastAPI middleware:
- It receives Jira and GitLab webhooks.
- It extracts the Jira key — from Jira’s payload directly, or from the GitLab branch or commit message with a regex.
- It hashes the key into the trace ID and maps the payload onto OpenTelemetry semantic conventions (
git.*,cicd.*, and a customworkflow.*namespace). - It exports over OTLP to a Collector, which sends traces to Jaeger and metrics to Prometheus. Grafana shows the dashboards.
Lead Time is calculated inline. A small state manager records the first commit timestamp for a key, and when the matching deployment arrives, it records the difference as a Prometheus histogram. No batch job, no separate pipeline.
Does it work
I ran a controlled comparison: three workflows with standard OpenTelemetry, three with the deterministic strategy. Same workflow, same tools, same network. The only thing that changed was where the trace ID came from.
| Metric | Standard OTel | Deterministic |
|---|---|---|
| Traces per workflow | 13.33 | 1.00 |
| Correlation success | 0% (0/3) | 100% (3/3) |
| Automated Lead Time | no data | 0% error vs. manual |
| Added overhead | — | +1.1 ms per event |
The standard approach calculated Lead Time zero times. The deterministic approach calculated it every time, and the automated values matched hand-calculated timestamps exactly.
The cost was 1.1 milliseconds per event for the hash and a state lookup. Webhook network latency is 50–200 ms, so the overhead doesn’t matter in practice.
The tradeoff
This is a technique, and it has clear limits. Naming them is part of using it correctly.
- It ran locally. No cloud deployment was tested.
- State is in memory. A restart loses Lead Time data. Production needs Redis or a small database.
- MD5 is fine for a controlled key space but predictable at scale. Production should use a salted HMAC-SHA256: still deterministic, but not guessable.
- It depends on the Jira key being in the branch or commit. If it isn’t, the trace fragments again. A git hook or CI check can enforce the convention.
And the obvious question: why not buy Datadog or LinearB, or just use GitLab’s built-in DORA metrics? For many teams, you should.
GitLab’s native metrics are a reasonable default, but they measure GitLab’s slice — merge request to deploy — with GitLab’s definitions. The moment the path crosses tools, or you want to own the measurement on open standards instead of renting it from whichever platform you happen to use, the built-in stops being enough.
Deterministic correlation on OpenTelemetry keeps the measurement vendor-neutral. The trace ID comes from your business key, not a proprietary agent, so you can change backends without re-instrumenting.
The next step
If you want to try this, you don’t need the full stack. The whole idea fits in one decision: pick a stable business identifier that every event in your workflow already carries, and derive the trace ID from it instead of generating a random one. A custom IdGenerator is the only OpenTelemetry-specific piece. Everything else is mapping payloads to spans.
The broader point is the part worth keeping:
A business identifier is often a better backbone for tracing long-running work than the technical IDs we usually reach for.
We anchor traces to container IDs, request IDs, process IDs. Those last as long as a request. A change takes days or weeks to reach production, and across that time the only stable thing is the business identity of the work: the ticket, the order, the customer.
When you can’t propagate context, derive it. You just have to agree on what makes two events the same.
Code and full write-up: github.com/NewerKey/idp-observ. This came out of my B.Sc. thesis in software engineering.
Next: Securing IDP-Observ’s Webhooks: Two Vendors, Two Different Auth Models