Skip to content

NewerKey/keythread

Repository files navigation

keythread

Deterministic cross-tool trace correlation for DORA metrics.

Webhooks carry no trace context. When an issue tracker, a git host, and a CI/CD system each fire independent webhooks for the same piece of work, there's normally no way to link them into one trace — so metrics like lead time for changes can't be computed automatically across tool boundaries.

keythread closes that gap with one technique: hash a business/correlation key (an issue key, a ticket ID — whatever your tracking system uses) into a deterministic OpenTelemetry trace ID with HMAC-SHA256. Every webhook that carries the same key lands in the same trace, regardless of which tool sent it or when.

issue key "PROJ-123"
        │
        ▼
HMAC-SHA256(secret, "proj-123")  ──►  same 128-bit trace ID every time
        │
        ├── Jira: issue created         ─┐
        ├── GitLab: commit pushed        ├─► all land in one OTel trace
        └── GitLab: deployment completed ─┘

Install

# library only
pip install keythread

# + the webhook server (FastAPI/uvicorn/typer)
pip install "keythread[server]"

# + OTLP/HTTP export instead of the default gRPC exporter
pip install "keythread[server,otlp-http]"

Quickstart (server)

cp .env.example .env
# fill in TRACE_ID_HMAC_SECRET / GITLAB_WEBHOOK_SECRET / JIRA_WEBHOOK_SECRET
# (there are no insecure defaults — the server refuses to start without them)

keythread serve                          # binds 0.0.0.0:8000 by default
keythread serve --host 127.0.0.1 --port 9000

keythread version                        # print the installed version
keythread --help / keythread serve --help

(From a clone rather than a pip install, prefix each command with uv run.)

curl localhost:8000/health/live
curl localhost:8000/health/ready
curl localhost:8000/metrics   # includes dora_lead_time_for_change_seconds

Point your Jira Automation rule / GitLab webhook config at POST /webhooks/jira and POST /webhooks/gitlab respectively, with the matching secret header (X-Webhook-Secret / X-Gitlab-Token). For any other tool (GitHub, Linear, ...), see Writing a new adapter below.

Quickstart (library)

from keythread import (
    JiraAdapter, GitLabAdapter, register_adapter,
    SpanFactory, WorkflowStateManager, Settings, setup_telemetry,
    process_webhook_event,
)

settings = Settings()  # reads TRACE_ID_HMAC_SECRET etc. from the environment

register_adapter("jira", JiraAdapter(settings.jira_webhook_secret, settings.trace_id_hmac_secret))
register_adapter("gitlab", GitLabAdapter(settings.gitlab_webhook_secret, settings.trace_id_hmac_secret))

tracer = setup_telemetry(settings)
span_factory = SpanFactory(tracer)
state_manager = WorkflowStateManager()

process_webhook_event("jira", jira_payload, span_factory, state_manager)
process_webhook_event("gitlab", gitlab_push_payload, span_factory, state_manager)
process_webhook_event("gitlab", gitlab_deploy_payload, span_factory, state_manager)

Writing a new adapter

SourceAdapter is a typing.Protocol — implement three methods and register the adapter under a source name; no subclassing, no plugin registry to configure:

from keythread import SourceAdapter, UnifiedEvent, register_adapter

class LinearAdapter:
    def verify_request(self, headers, body) -> bool: ...
    def extract_correlation_key(self, payload: dict) -> str | None: ...
    def map_event(self, payload: dict) -> UnifiedEvent | None: ...

register_adapter("linear", LinearAdapter())

Any object satisfying those three methods works — SourceAdapter is structural, not nominal. adapters/github.py is a real (not sketched) second built-in adapter and the reference to copy from — it deliberately differs from Jira/GitLab in ways worth studying before writing your own:

  • Auth scheme: GitHub signs the raw request body with HMAC-SHA256 (X-Hub-Signature-256), unlike Jira/GitLab's plain shared-secret header — verify_request receives body: bytes for exactly this reason.
  • No native issue-key format: GitHub has no Jira-style PROJ-123; correlation keys are derived from issue numbers (#42, branch names like 42-fix-thing), normalized to GH-42.
  • Event kind isn't in the payload: GitHub sends it via the X-GitHub-Event header, but map_event only receives the body — the adapter infers kind from payload shape instead (see _infer_event_kind), a real limitation worth knowing about if your tool's payloads are ambiguous without their dispatch header.

To run the FastAPI server with a custom adapter instead of (or alongside) the Jira/GitLab built-ins, pass it to create_app:

import uvicorn
from keythread.adapters.github import GitHubAdapter
from keythread.server.app import create_app
from keythread.settings import Settings

settings = Settings()
app = create_app(
    settings,
    adapters={
        "github": GitHubAdapter(
            webhook_secret="...",  # GitHub webhook's configured secret
            trace_id_hmac_secret=settings.trace_id_hmac_secret,
        )
    },
)
uvicorn.run(app)

A key that collides with a built-in ("jira" or "gitlab") replaces it — useful for swapping in a customized adapter without forking keythread. The keythread serve CLI command itself only wires up the Jira/GitLab built-ins; teams on other tools use create_app directly as shown above (a few lines, not a fork) instead of the CLI entry point.

Architecture

Module Responsibility
correlation.py HMAC-SHA256 trace/span ID generation
schema.py UnifiedEvent, EventType (closed enum of DORA lifecycle stages)
id_generator.py DeterministicIdGenerator — injects trace/span IDs into OTel via a ContextVar
telemetry.py setup_telemetry — accepts an injected TracerProvider for testing
span_factory.py Maps a UnifiedEvent to an OTel span with semantic-convention attributes
state.py WorkflowStateManager — trace-aware commit/deploy correlation state, TTL + lazy eviction
metrics.py Prometheus DORA metrics (deployment frequency, lead time)
payload_sink.py Opt-in raw-payload persistence (no-op by default)
adapters/ SourceAdapter protocol, registry, built-in Jira/GitLab/GitHub adapters
webhooks/ Request verification + processing orchestration
server/ Optional FastAPI app (server extra)

Security

  • Fail closed: Settings requires TRACE_ID_HMAC_SECRET, GITLAB_WEBHOOK_SECRET, and JIRA_WEBHOOK_SECRET — the process refuses to start if any are unset. There are no placeholder fallbacks.
  • Constant-time comparisons: webhook credential checks use hmac.compare_digest.
  • Opt-in disk writes: payloads are never written to disk unless you explicitly configure a FilePayloadSink.

Development

uv sync --all-extras --dev
uv run ruff check .
uv run ruff format --check .
uv run pytest --cov=src --cov-report=term-missing

Status & contributing

This is active, ongoing work, not a finished v1 — Jira/GitLab/GitHub are the only built-in adapters so far, and design decisions (the correlation key concept, the TTL-based state manager, what belongs in the core vs. an adapter) are still being stress-tested against real tools. Feedback on any of that is genuinely wanted, not a formality:

  • Found a bug, or a design decision that doesn't hold up for your tool? Open an issue.
  • Wrote an adapter for a tool not listed here (GitHub Issues + Actions, Linear, Azure DevOps, Jenkins, CircleCI, Bitbucket, ...)? A PR is welcome — adapters/github.py is the reference to copy from, and the existing adapter test suites (tests/adapters/) show the coverage a new one is expected to have.
  • Disagree with a tradeoff (the dropped CORRELATION_STRATEGY toggle, the TTL default, the closed EventType enum, anything in Security)? Say so in an issue — these were judgment calls made with the information available at the time, not settled conclusions.

License

MIT — see LICENSE.

About

Deterministic cross-tool trace correlation for DORA metrics — hash a business key into an OpenTelemetry trace ID so async webhooks from different tools land in the same trace.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors