Skip to content

Telemetry

OpenTelemetry spans and structured JSON logging (ADR-0022). A deliberate leaf module: it imports nothing from groundkit, because retrieval.search imports this module to instrument its search span, so an import in the other direction would be circular — the RetrievalMode and stage literal types below are re-declared locally rather than imported from retrieval.search or evals.schema, which define the same shapes for their own reasons.

opentelemetry-api is a base dependency (ADR-0022 decision 1), so it is imported unconditionally — there is no guarded import and no hand-rolled no-op tracer shim to keep in signature parity by hand. With no opentelemetry-sdk installed and nothing configured, get_tracer() still returns a tracer, but every span it creates is non-recording: no export, no collector, no error. That's documented opentelemetry-api behavior, not a groundkit fallback, and it's why this module is excluded from the core-subset coverage gate on the same test that keeps runtime.py inside it (see CONTRIBUTING.md): whether a module's absence changes what a response contains. A tracer's absence never does — it changes only what gets shipped to a collector alongside the response, so this module is covered by the whole-package 80% floor instead, the same treatment service/ gets for the same reason.

The otel extra (not installed by default) is what turns a span into something a collector actually receives; see ADR-0022 for the dependency shape and the span attribute allowlist that keeps query text, source paths and document content out of every exported span.

groundkit.telemetry

OpenTelemetry spans and structured JSON logging (ADR-0022).

This is a LEAF module: it imports nothing from groundkit. retrieval/search.py imports this module to instrument its search span, so an import in the other direction would be circular. The RetrievalMode and Stage literal types below are therefore re-declared locally rather than imported from retrieval.search or evals.schema, which define the same shapes for their own reasons. evals/metrics.py is this repo's existing precedent for a deliberate leaf: no groundkit imports, so its tests construct plain values and this module's tests construct plain values too.

opentelemetry-api is a base dependency (ADR-0022 decision 1), so it is imported unconditionally below. There is no guarded import, no try/except ImportError, and no hand-rolled no-op tracer shim — the ADR rejects that shape explicitly: a hand-maintained fake has to be kept in signature parity with the real tracer by hand, which is exactly the defect class tests/test_protocol_conformance.py exists to catch elsewhere in this codebase. With no opentelemetry-sdk installed and nothing configured, get_tracer() returns a tracer whose spans are non-recording: no export, no collector, no configuration, no error. That is documented opentelemetry-api behaviour, not a groundkit fallback, and SPEC.md §2's fail-closed rule does not apply to it — fail-closed governs things whose absence changes an answer, and a tracer's absence changes no answer (ADR-0022 decision 1).

The span attribute allowlist (ADR-0022 decision 3)

:func:span_attributes is the only way an instrumentation site may attach data to a span. It is keyword-only and declares no **kwargs, so an attribute outside the allowlist is a type error caught by mypy and by the call site's own test suite, not something that depends on a reviewer noticing it. That shape mirrors ADR-0001 hazard 3: a **kwargs that silently absorbs whatever it is handed is a defect class this repo has already been bitten by, and the fix here is structural rather than a convention to remember.

Permitted, and nothing else: collection name, retrieval mode, retrieval stage, top_k, result/candidate/chunk/document counts, duration, a typed failure code, request id, HTTP status, the embedding identity triple ADR-0004 defines (provider, model name, dimensions), and the chat provider/model pair decision 5 permits on the synthesize span specifically. Every one of those is either a bounded enum, a count, a timing, or configuration the operator chose — never a value a user or a document supplied, which is the line the allowlist is actually drawing.

Forbidden on every span, at every level — not just at INFO, because a span has no levels and every attribute on a recording span leaves the process: query text, chunk or document content, citation spans, absolute source paths, and the values inside a metadata_filter (its keys would be fine; its values are exactly where a tenant id or a customer name would appear). There is no parameter for any of these on :func:span_attributes and there must never be one — see tests/test_telemetry.py for the tests that hold this allowlist to that promise structurally rather than by review.

Attribute key naming scheme

Keys are dotted, OTel-style, under a groundkit. prefix. Concepts specific to how a retrieval query was shaped nest under groundkit.retrieval.*; the embedding identity triple nests under groundkit.embedding.*; everything else — counts, duration, failure kind, request id, HTTP status — is shared across the ingest/retrieve/ synthesize span sites and stays flat under groundkit.*:

  • collection -> groundkit.collection
  • retrieval_mode -> groundkit.retrieval.mode
  • stage -> groundkit.retrieval.stage
  • top_k -> groundkit.retrieval.top_k
  • result_count -> groundkit.result_count
  • candidate_count -> groundkit.candidate_count
  • chunk_count -> groundkit.chunk_count
  • document_count -> groundkit.document_count
  • duration_ms -> groundkit.duration_ms
  • failure_kind -> groundkit.failure_kind
  • request_id -> groundkit.request_id
  • http_status -> groundkit.http.status_code
  • embedding_provider -> groundkit.embedding.provider
  • embedding_model -> groundkit.embedding.model
  • embedding_dimensions -> groundkit.embedding.dimensions
  • chat_provider -> groundkit.chat.provider
  • chat_model -> groundkit.chat.model
Structured JSON logging

There is no central logging configuration in this repo today — the CLI never calls logging.basicConfig. :func:configure_logging is the new entry point that makes GROUNDKIT_LOG_FORMAT mean anything, and it is deliberately a thin logging.Formatter subclass plus a handler attachment rather than a dependency on structlog or loguru (ADR-0022's alternatives section): every module already logs through stdlib logging, so a formatter subclass gets the same JSON with no new dependency and no repo-wide rewrite.

:class:JsonLogFormatter does not change any existing log message string (ADR-0022 decision 4) — call sites keep their key=value message text and gain structured fields via extra={...} alongside it, so assertions written against today's message strings keep meaning what they meant.

JsonLogFormatter

Bases: Formatter

Render one JSON object per log record.

The object always carries timestamp (ISO 8601, UTC — machine- parseable across a collector's timezone rather than the host's local one), level, logger, and message (the same string the record's msg %% args would have produced under the human-readable formatter — ADR-0022 decision 4 keeps existing message text unchanged). Every field the call site passed via extra={...} is merged in alongside those four; an exception field is added when the record carries exception info.

A field from extra that collides with one of the four base keys is dropped rather than allowed to overwrite it — an accidental extra={"level": ...} must not be able to corrupt the record's actual severity. Non-JSON-serializable values are coerced with str() (json.dumps(..., default=str)) rather than raising, so a bad value in extra can never crash the logging call site that produced it.

format
format(record: LogRecord) -> str

Render record as a single-line JSON string.

Parameters:

Name Type Description Default
record LogRecord

The log record being emitted.

required

Returns:

Type Description
str

A JSON-encoded object; see the class docstring for its shape.

Source code in src/groundkit/telemetry.py
def format(self, record: logging.LogRecord) -> str:
    """Render ``record`` as a single-line JSON string.

    Args:
        record: The log record being emitted.

    Returns:
        A JSON-encoded object; see the class docstring for its shape.
    """
    payload: dict[str, Any] = {
        **_extra_fields(record),
        "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
        "level": record.levelname,
        "logger": record.name,
        "message": record.getMessage(),
    }
    if record.exc_info:
        payload["exception"] = self.formatException(record.exc_info)
    return json.dumps(payload, default=str)

configure_logging

configure_logging(level: int = logging.INFO) -> None

Attach one stderr log handler to the root logger, idempotently.

There is no central logging configuration in this repo today, so this is the entry point that makes GROUNDKIT_LOG_FORMAT mean anything. GROUNDKIT_LOG_FORMAT=json selects :class:JsonLogFormatter; any other value, or unset, keeps human-readable formatting, which is the default (ADR-0022 decision 4) — a local grk search that started printing JSON to a developer's terminal would be a regression in the tool's primary use, not an improvement.

The handler always writes to stderr, never stdout. service/mcp_server.py's _force_logging_to_stderr exists because ADR-0014 decision 5 makes stdout JSON-RPC-only on the stdio transport — one stray log record on stdout corrupts the rest of that session. This function does not fight that guard: it never attaches to stdout in the first place, so if run_stdio later calls _force_logging_to_stderr, that sweep finds nothing of this function's to re-point.

Calling this twice does not attach a second handler — a handler with :data:_HANDLER_NAME already present on the root logger is treated as "already configured," and the call becomes a level update only. That matters because nothing prevents configure_logging from being called from more than one entry point (a CLI command and a service startup path, say) without either one knowing the other already ran.

Parameters:

Name Type Description Default
level int

The root logger's level. Applied on every call — including a repeat call that finds the handler already attached — so a caller can still raise or lower verbosity without first checking whether an earlier call already configured logging.

INFO
Source code in src/groundkit/telemetry.py
def configure_logging(level: int = logging.INFO) -> None:
    """Attach one stderr log handler to the root logger, idempotently.

    There is no central logging configuration in this repo today, so this
    is the entry point that makes ``GROUNDKIT_LOG_FORMAT`` mean anything.
    ``GROUNDKIT_LOG_FORMAT=json`` selects :class:`JsonLogFormatter`; any
    other value, or unset, keeps human-readable formatting, which is the
    default (ADR-0022 decision 4) — a local ``grk search`` that started
    printing JSON to a developer's terminal would be a regression in the
    tool's primary use, not an improvement.

    The handler always writes to **stderr**, never stdout.
    ``service/mcp_server.py``'s ``_force_logging_to_stderr`` exists because
    ADR-0014 decision 5 makes stdout JSON-RPC-only on the stdio transport —
    one stray log record on stdout corrupts the rest of that session. This
    function does not fight that guard: it never attaches to stdout in the
    first place, so if ``run_stdio`` later calls
    ``_force_logging_to_stderr``, that sweep finds nothing of this
    function's to re-point.

    Calling this twice does not attach a second handler — a handler with
    :data:`_HANDLER_NAME` already present on the root logger is treated as
    "already configured," and the call becomes a level update only. That
    matters because nothing prevents ``configure_logging`` from being
    called from more than one entry point (a CLI command and a service
    startup path, say) without either one knowing the other already ran.

    Args:
        level: The root logger's level. Applied on every call — including a
            repeat call that finds the handler already attached — so a
            caller can still raise or lower verbosity without first
            checking whether an earlier call already configured logging.
    """
    root = logging.getLogger()
    if any(handler.name == _HANDLER_NAME for handler in root.handlers):
        root.setLevel(level)
        return

    handler = logging.StreamHandler(sys.stderr)
    handler.set_name(_HANDLER_NAME)
    handler.setFormatter(_select_formatter())
    root.addHandler(handler)
    root.setLevel(level)

configure_tracing

configure_tracing() -> None

Install an SDK tracer provider when the environment asks for one.

This function is why any span in this package is ever recorded. opentelemetry-api on its own returns a ProxyTracerProvider whose spans are non-recording, and — this is the part that is easy to get wrong and was gotten wrong here first — installing opentelemetry-sdk and setting the OTEL_* variables does not change that. Those variables are read by opentelemetry.sdk._configuration, which runs under the opentelemetry-instrument launcher, not on import. Absent an explicit set_tracer_provider call, a fully-installed, fully-configured process still exports nothing, silently and with no error — a verification against the compose stack found exactly that.

The SDK import here is guarded, and it is the only guarded opentelemetry import in this package. That does not contradict ADR-0022 decision 1, which requires instrumentation sites to import unconditionally and rejects a hand-rolled no-op tracer shim: the sites still import only opentelemetry-api and still need no guard, and no shim exists — when the SDK is absent the real opentelemetry-api no-op path is what runs. A bootstrap that must tolerate an optional extra being absent is the one place a guard genuinely belongs.

Configuration is entirely the standard OTEL_* environment (ADR-0022 decision 2) — Resource.create() reads OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES, and each exporter's own constructor reads the matching endpoint variable. Nothing is passed explicitly, so there is no second vocabulary and no precedence rule to invent for those, and a typo in an endpoint produces the SDK's own error rather than a groundkit ConfigurationError. The wire protocol is the one exception: an OTLPSpanExporter reads its endpoint from the environment but not its own transport — that is fixed by which class was imported, HTTP/protobuf in the http module and gRPC in the grpc one — so this function reads OTEL_EXPORTER_OTLP_*PROTOCOL itself and picks the class. A version of this that always imported the HTTP class shipped first: it applied cleanly against a grpc-configured collector and then failed at every export call, because nothing here was reading the variable the docs already claimed it did.

Calling this is optional and its absence changes no answer, so a missing SDK is a warning rather than a failure — SPEC.md §2's fail-closed rule governs things whose absence changes a result, and ADR-0022 decision 1 says so explicitly to stop this being cited as precedent either way. What is not softened is the export path: a configured endpoint that cannot be reached surfaces as the SDK's own export error.

Idempotent: a second call finds a real provider already registered and returns without replacing it (OTel itself only warns and ignores a second set_tracer_provider, which would otherwise make the outcome depend on call order).

Source code in src/groundkit/telemetry.py
def configure_tracing() -> None:
    """Install an SDK tracer provider when the environment asks for one.

    **This function is why any span in this package is ever recorded.**
    ``opentelemetry-api`` on its own returns a ``ProxyTracerProvider`` whose
    spans are non-recording, and — this is the part that is easy to get
    wrong and was gotten wrong here first — *installing*
    ``opentelemetry-sdk`` and setting the ``OTEL_*`` variables does not
    change that. Those variables are read by
    ``opentelemetry.sdk._configuration``, which runs under the
    ``opentelemetry-instrument`` launcher, not on import. Absent an explicit
    ``set_tracer_provider`` call, a fully-installed, fully-configured
    process still exports nothing, silently and with no error — a
    verification against the compose stack found exactly that.

    The SDK import here is guarded, and it is the **only** guarded
    opentelemetry import in this package. That does not contradict ADR-0022
    decision 1, which requires *instrumentation sites* to import
    unconditionally and rejects a hand-rolled no-op tracer shim: the sites
    still import only ``opentelemetry-api`` and still need no guard, and no
    shim exists — when the SDK is absent the real ``opentelemetry-api``
    no-op path is what runs. A bootstrap that must tolerate an optional
    extra being absent is the one place a guard genuinely belongs.

    Configuration is entirely the standard ``OTEL_*`` environment (ADR-0022
    decision 2) — ``Resource.create()`` reads ``OTEL_SERVICE_NAME`` and
    ``OTEL_RESOURCE_ATTRIBUTES``, and each exporter's own constructor reads
    the matching endpoint variable. Nothing is passed explicitly, so there is
    no second vocabulary and no precedence rule to invent for those, and a
    typo in an endpoint produces the SDK's own error rather than a groundkit
    ``ConfigurationError``. The wire *protocol* is the one exception: an
    ``OTLPSpanExporter`` reads its endpoint from the environment but not its
    own transport — that is fixed by which class was imported, HTTP/protobuf
    in the ``http`` module and gRPC in the ``grpc`` one — so this function
    reads ``OTEL_EXPORTER_OTLP_*PROTOCOL`` itself and picks the class. A
    version of this that always imported the HTTP class shipped first: it
    applied cleanly against a ``grpc``-configured collector and then failed
    at every export call, because nothing here was reading the variable the
    docs already claimed it did.

    Calling this is optional and its absence changes no answer, so a missing
    SDK is a warning rather than a failure — SPEC.md §2's fail-closed rule
    governs things whose absence changes a *result*, and ADR-0022 decision 1
    says so explicitly to stop this being cited as precedent either way.
    What is *not* softened is the export path: a configured endpoint that
    cannot be reached surfaces as the SDK's own export error.

    Idempotent: a second call finds a real provider already registered and
    returns without replacing it (OTel itself only warns and ignores a
    second ``set_tracer_provider``, which would otherwise make the outcome
    depend on call order).
    """
    if not _export_requested():
        return
    if not isinstance(trace.get_tracer_provider(), trace.ProxyTracerProvider):
        return

    protocol = _requested_protocol()
    use_grpc = protocol == _GRPC_PROTOCOL_VALUE

    try:
        from opentelemetry.sdk.resources import Resource
        from opentelemetry.sdk.trace import TracerProvider
        from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
    except ImportError:
        logger.warning(
            "tracing requested via OTEL_* but the 'otel' extra is not installed; "
            "no spans will be exported (install groundkit[otel])",
        )
        return

    exporter: SpanExporter
    # The transport is a choice of *which class* to import — OTLPSpanExporter
    # in the http module always speaks HTTP/protobuf and the one in the grpc
    # module always speaks gRPC, neither reads OTEL_EXPORTER_OTLP_*PROTOCOL
    # itself. Selecting the class here is what makes that variable mean
    # anything for a manually-constructed provider like this one, rather than
    # only for the auto-instrumentation launcher's entry-point resolution.
    if use_grpc:
        try:
            from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
                OTLPSpanExporter as GrpcSpanExporter,
            )
        except ImportError:
            logger.warning(
                "tracing requested via OTEL_EXPORTER_OTLP_*PROTOCOL=grpc but the grpc "
                "exporter is not installed; no spans will be exported "
                "(install groundkit[otel])",
            )
            return
        exporter = GrpcSpanExporter()
    else:
        try:
            from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
                OTLPSpanExporter as HttpSpanExporter,
            )
        except ImportError:
            logger.warning(
                "tracing requested via OTEL_* but the 'otel' extra is not installed; "
                "no spans will be exported (install groundkit[otel])",
            )
            return
        exporter = HttpSpanExporter()

    provider = TracerProvider(resource=Resource.create())
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)

get_tracer

get_tracer() -> Tracer

Return the package-wide OpenTelemetry tracer.

Every instrumentation site (Indexer.index_source, Indexer.index_directory, Retriever.search, Synthesizer.synthesize) calls this rather than opentelemetry.trace.get_tracer directly, so they all publish under the same "groundkit" instrumentation scope.

With no opentelemetry-sdk installed and no exporter configured, the returned tracer's spans are non-recording: span.is_recording() is False, attribute calls are accepted and discarded, and nothing is exported. That is the documented behaviour of opentelemetry-api alone (ADR-0022 decision 1), not a groundkit fallback path, and it costs the caller nothing to instrument unconditionally.

Returns:

Type Description
Tracer

A Tracer bound to the "groundkit" instrumentation scope.

Source code in src/groundkit/telemetry.py
def get_tracer() -> Tracer:
    """Return the package-wide OpenTelemetry tracer.

    Every instrumentation site (``Indexer.index_source``,
    ``Indexer.index_directory``, ``Retriever.search``,
    ``Synthesizer.synthesize``) calls this rather than
    ``opentelemetry.trace.get_tracer`` directly, so they all publish under
    the same ``"groundkit"`` instrumentation scope.

    With no ``opentelemetry-sdk`` installed and no exporter configured, the
    returned tracer's spans are non-recording: ``span.is_recording()`` is
    ``False``, attribute calls are accepted and discarded, and nothing is
    exported. That is the documented behaviour of ``opentelemetry-api``
    alone (ADR-0022 decision 1), not a groundkit fallback path, and it costs
    the caller nothing to instrument unconditionally.

    Returns:
        A ``Tracer`` bound to the ``"groundkit"`` instrumentation scope.
    """
    return trace.get_tracer(_TRACER_NAME)

span_attributes

span_attributes(
    *,
    collection: str | None = None,
    retrieval_mode: RetrievalMode | None = None,
    stage: Stage | None = None,
    top_k: int | None = None,
    result_count: int | None = None,
    candidate_count: int | None = None,
    chunk_count: int | None = None,
    document_count: int | None = None,
    duration_ms: float | None = None,
    failure_kind: str | None = None,
    request_id: str | None = None,
    http_status: int | None = None,
    embedding_provider: str | None = None,
    embedding_model: str | None = None,
    embedding_dimensions: int | None = None,
    chat_provider: str | None = None,
    chat_model: str | None = None,
) -> dict[str, str | int | float]

Build a span attribute dict from the ADR-0022 allowlist, and nothing else.

Every permitted attribute is its own explicitly typed keyword parameter. There is no **kwargs: an attribute outside this allowlist is a TypeError at the call site (and, before that, a mypy error), rather than something a reviewer has to notice is missing from a list. See the module docstring for the full allowlist, the forbidden set it deliberately excludes, and the key naming scheme applied below.

Parameters:

Name Type Description Default
collection str | None

The collection name, when reachable at the call site.

None
retrieval_mode RetrievalMode | None

"bm25", "dense", or "hybrid".

None
stage Stage | None

"bm25", "dense", "fusion", or "rerank".

None
top_k int | None

The requested result cutoff.

None
result_count int | None

Number of results returned.

None
candidate_count int | None

Number of candidates considered before cutoff.

None
chunk_count int | None

Number of chunks involved (e.g. produced by ingest).

None
document_count int | None

Number of documents involved.

None
duration_ms float | None

Elapsed wall-clock time in milliseconds.

None
failure_kind str | None

A typed, bounded failure label, when the span recorded a failure. ADR-0022 decision 3 names :mod:groundkit.service.errors's rendered kind (e.g. "index_inconsistent") as the value it had in mind, and that is what a caller at the service boundary should pass. The retrieve and synthesize span sites sit below that boundary, though: retrieval/ and providers/ importing service/ would invert the package layering, so they pass the raised exception's class name (e.g. "RetrievalError") instead. Both are closed, non-user-supplied vocabularies that cannot carry content, which is the property the allowlist is protecting; they are simply two different taxonomies, and a consumer reading this attribute across span sites should not assume one.

None
request_id str | None

The request id assigned at the service boundary.

None
http_status int | None

The HTTP status code returned for the request.

None
embedding_provider str | None

ADR-0004's embedding identity triple, part 1.

None
embedding_model str | None

ADR-0004's embedding identity triple, part 2.

None
embedding_dimensions int | None

ADR-0004's embedding identity triple, part 3.

None
chat_provider str | None

Chat provider backing a synthesis call.

None
chat_model str | None

Chat model identifier backing a synthesis call. ADR-0022 decision 5 permits "model identity" on the synthesize span specifically, and this pair is it — kept distinct from the embedding_* triple above because they name different things and a collector that conflated them would attribute a slow chat model to the embedding backend. Neither carries prompt or completion text; both are configuration the operator chose, not content a user supplied.

None

Returns:

Type Description
dict[str, str | int | float]

A dict containing only the arguments that were not None, keyed

dict[str, str | int | float]

by the dotted groundkit.* name documented in the module

dict[str, str | int | float]

docstring. OTel attribute values may not be None, so an omitted

dict[str, str | int | float]

argument is omitted from the result rather than included with a

dict[str, str | int | float]

None value.

Source code in src/groundkit/telemetry.py
def span_attributes(
    *,
    collection: str | None = None,
    retrieval_mode: RetrievalMode | None = None,
    stage: Stage | None = None,
    top_k: int | None = None,
    result_count: int | None = None,
    candidate_count: int | None = None,
    chunk_count: int | None = None,
    document_count: int | None = None,
    duration_ms: float | None = None,
    failure_kind: str | None = None,
    request_id: str | None = None,
    http_status: int | None = None,
    embedding_provider: str | None = None,
    embedding_model: str | None = None,
    embedding_dimensions: int | None = None,
    chat_provider: str | None = None,
    chat_model: str | None = None,
) -> dict[str, str | int | float]:
    """Build a span attribute dict from the ADR-0022 allowlist, and nothing else.

    Every permitted attribute is its own explicitly typed keyword parameter.
    There is no ``**kwargs``: an attribute outside this allowlist is a
    ``TypeError`` at the call site (and, before that, a mypy error), rather
    than something a reviewer has to notice is missing from a list. See the
    module docstring for the full allowlist, the forbidden set it
    deliberately excludes, and the key naming scheme applied below.

    Args:
        collection: The collection name, when reachable at the call site.
        retrieval_mode: ``"bm25"``, ``"dense"``, or ``"hybrid"``.
        stage: ``"bm25"``, ``"dense"``, ``"fusion"``, or ``"rerank"``.
        top_k: The requested result cutoff.
        result_count: Number of results returned.
        candidate_count: Number of candidates considered before cutoff.
        chunk_count: Number of chunks involved (e.g. produced by ingest).
        document_count: Number of documents involved.
        duration_ms: Elapsed wall-clock time in milliseconds.
        failure_kind: A typed, bounded failure label, when the span
            recorded a failure. ADR-0022 decision 3 names
            :mod:`groundkit.service.errors`'s rendered ``kind`` (e.g.
            ``"index_inconsistent"``) as the value it had in mind, and that
            is what a caller at the service boundary should pass. The
            ``retrieve`` and ``synthesize`` span sites sit *below* that
            boundary, though: ``retrieval/`` and ``providers/`` importing
            ``service/`` would invert the package layering, so they pass the
            raised exception's class name (e.g. ``"RetrievalError"``)
            instead. Both are closed, non-user-supplied vocabularies that
            cannot carry content, which is the property the allowlist is
            protecting; they are simply two different taxonomies, and a
            consumer reading this attribute across span sites should not
            assume one.
        request_id: The request id assigned at the service boundary.
        http_status: The HTTP status code returned for the request.
        embedding_provider: ADR-0004's embedding identity triple, part 1.
        embedding_model: ADR-0004's embedding identity triple, part 2.
        embedding_dimensions: ADR-0004's embedding identity triple, part 3.
        chat_provider: Chat provider backing a synthesis call.
        chat_model: Chat model identifier backing a synthesis call.
            ADR-0022 decision 5 permits "model identity" on the
            ``synthesize`` span specifically, and this pair is it — kept
            distinct from the ``embedding_*`` triple above because they name
            different things and a collector that conflated them would
            attribute a slow chat model to the embedding backend. Neither
            carries prompt or completion text; both are configuration the
            operator chose, not content a user supplied.

    Returns:
        A dict containing only the arguments that were not ``None``, keyed
        by the dotted ``groundkit.*`` name documented in the module
        docstring. OTel attribute values may not be ``None``, so an omitted
        argument is omitted from the result rather than included with a
        ``None`` value.
    """
    attributes: dict[str, str | int | float] = {}
    if collection is not None:
        attributes["groundkit.collection"] = collection
    if retrieval_mode is not None:
        attributes["groundkit.retrieval.mode"] = retrieval_mode
    if stage is not None:
        attributes["groundkit.retrieval.stage"] = stage
    if top_k is not None:
        attributes["groundkit.retrieval.top_k"] = top_k
    if result_count is not None:
        attributes["groundkit.result_count"] = result_count
    if candidate_count is not None:
        attributes["groundkit.candidate_count"] = candidate_count
    if chunk_count is not None:
        attributes["groundkit.chunk_count"] = chunk_count
    if document_count is not None:
        attributes["groundkit.document_count"] = document_count
    if duration_ms is not None:
        attributes["groundkit.duration_ms"] = duration_ms
    if failure_kind is not None:
        attributes["groundkit.failure_kind"] = failure_kind
    if request_id is not None:
        attributes["groundkit.request_id"] = request_id
    if http_status is not None:
        attributes["groundkit.http.status_code"] = http_status
    if embedding_provider is not None:
        attributes["groundkit.embedding.provider"] = embedding_provider
    if embedding_model is not None:
        attributes["groundkit.embedding.model"] = embedding_model
    if embedding_dimensions is not None:
        attributes["groundkit.embedding.dimensions"] = embedding_dimensions
    if chat_provider is not None:
        attributes["groundkit.chat.provider"] = chat_provider
    if chat_model is not None:
        attributes["groundkit.chat.model"] = chat_model
    return attributes