Skip to content

Per-request custom headers for tracing#1173

Merged
joein merged 3 commits into
devfrom
tracing-header
May 7, 2026
Merged

Per-request custom headers for tracing#1173
joein merged 3 commits into
devfrom
tracing-header

Conversation

@generall

Copy link
Copy Markdown
Member

Depends on: qdrant/qdrant#8402

AI generated:

We need to implement tracing ID support - each request should have a unique ID,
which would be passed in headers to the request. The problem is that I probably don't want to change signature of every method.

How it would be possible to implement this functionality with least invasive way?

Manually tested with:

Details
"""
Examples of using context headers with Qdrant client.

Context headers are injected into every request made within the `headers` /
`async_headers` context manager block — both REST and gRPC transports are
supported automatically.
"""

import asyncio

from qdrant_client import AsyncQdrantClient, QdrantClient, async_headers, headers


def sync_rest():
    client = QdrantClient(url="http://localhost:6333", api_key="qdrant")

    with headers({"x-tracing-id": "sync-rest-trace"}):
        collections = client.get_collections()
        print("Sync REST collections:", collections)

    client.close()


def sync_grpc():
    client = QdrantClient(url="http://localhost:6333", prefer_grpc=True, api_key="qdrant")

    with headers({"x-tracing-id": "sync-grpc-trace"}):
        collections = client.get_collections()
        print("Sync gRPC collections:", collections)

    client.close()


async def async_rest():
    client = AsyncQdrantClient(url="http://localhost:6333", api_key="qdrant")

    async with async_headers({"x-tracing-id": "async-rest-trace"}):
        collections = await client.get_collections()
        print("Async REST collections:", collections)

    await client.close()


async def async_grpc():
    client = AsyncQdrantClient(url="http://localhost:6333", prefer_grpc=True, api_key="qdrant")

    async with async_headers({"x-tracing-id": "async-grpc-trace"}):
        collections = await client.get_collections()
        print("Async gRPC collections:", collections)

    await client.close()


if __name__ == "__main__":
    sync_rest()
    sync_grpc()
    asyncio.run(async_rest())
    asyncio.run(async_grpc())

@netlify

netlify Bot commented Mar 14, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit ee56bb0
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/69fccab7224a5d00092796b3
😎 Deploy Preview https://deploy-preview-1173--poetic-froyo-8baba7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Mar 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR introduces context-local header propagation to the Qdrant client. A new context_headers.py module provides ContextVar-backed functions for managing headers: get_context_headers() for retrieval, headers() and async_headers() context managers for scoped management, and rest_headers_middleware() and async_rest_headers_middleware() for REST API injection. The synchronous client (qdrant_remote.py) and asynchronous client (async_qdrant_remote.py) are updated to register REST middleware. The gRPC connection layer (connection.py) is enhanced to append context headers to metadata. Tests validate header propagation across sync/async contexts and code generation tooling is updated to handle the async middleware variant.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: adding support for custom headers (specifically for tracing) on a per-request basis via context managers.
Description check ✅ Passed The description is directly related to the changeset, explaining the motivation and approach for adding tracing ID support through context managers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tracing-header

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@qdrant_client/connection.py`:
- Around line 176-177: The metadata list is appended with get_context_headers()
causing duplicate keys; update both the sync interceptor block (where metadata
is built from new_metadata/client_call_details.metadata and appended with
get_context_headers()) and the async interceptor block to first remove any
existing entries whose key matches a context header key, then extend the
metadata with context headers; specifically, in the places that reference
metadata, new_metadata, client_call_details.metadata and get_context_headers(),
filter out entries with keys present in get_context_headers().keys() (preserving
order for remaining entries) before adding the context headers so context
headers deterministically overwrite conflicting keys.

In `@qdrant_client/context_headers.py`:
- Around line 5-9: _context_headers currently uses a mutable default dict and
get_context_headers returns the internal dict directly, risking cross-context
mutation; change the ContextVar declaration to use a default of None (or no
default) and update all setters/getters (e.g., get_context_headers,
set_context_headers, and any clear_context_headers) to treat the stored value as
optional, always store copies when calling _context_headers.set(...) and have
get_context_headers return a shallow copy (or an empty dict copy) rather than
the internal object so external mutation cannot taint the ContextVar across
contexts; apply the same copy-on-set/copy-on-get approach to the other related
functions referenced in the diff.
- Around line 34-43: The two middleware functions rest_headers_middleware and
async_rest_headers_middleware lack type annotations and fail mypy; update their
signatures to include explicit types: annotate the request parameter as
starlette.requests.Request (or the project's Request alias), annotate call_next
as Callable[[Request], Response] for the sync middleware and Callable[[Request],
Awaitable[Response]] for the async middleware, and set return types to Response
and Awaitable[Response] respectively; import the needed types (Request,
Response, Callable, Awaitable) and adjust any existing imports accordingly so
both function definitions are fully typed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 221adc5e-726a-4bd7-8f7d-4f526ba5f5c4

📥 Commits

Reviewing files that changed from the base of the PR and between c7a5bef and 25c43e9.

📒 Files selected for processing (7)
  • qdrant_client/__init__.py
  • qdrant_client/async_qdrant_remote.py
  • qdrant_client/connection.py
  • qdrant_client/context_headers.py
  • qdrant_client/qdrant_remote.py
  • tests/test_tracing.py
  • tools/async_client_generator/remote_generator.py

Comment on lines +176 to +177
for key, value in get_context_headers().items():
metadata.append((key, value))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Python grpcio client metadata, if the same metadata key is added multiple times, are all values transmitted and is there a defined precedence order for server-side consumption?

💡 Result:

Yes—all values are transmitted.

  • In grpcio (Python), call metadata is represented as an ordered sequence of (key, value) pairs, and the same key may appear multiple times (i.e., it’s a multimap, not a dict). (grpc.github.io)
  • On the wire, gRPC metadata is carried in HTTP/2 headers, and while general header order isn’t guaranteed, the gRPC over HTTP/2 protocol states that ordering is preserved for values with duplicate header names. (grpc.github.io)

Precedence: gRPC does not define a “winner” (no first-wins/last-wins rule) for server-side consumption. Servers receive repeated entries; any precedence is application/framework-specific (e.g., if you convert metadata to a map/dict yourself, you may implicitly drop duplicates and end up with “last one wins,” but that’s not a gRPC guarantee). (grpc.github.io)

Citations:


🏁 Script executed:

cat -n qdrant_client/connection.py | sed -n '170,185p'

Repository: qdrant/qdrant-client

Length of output: 858


🏁 Script executed:

cat -n qdrant_client/connection.py | sed -n '230,250p'

Repository: qdrant/qdrant-client

Length of output: 1043


🏁 Script executed:

cd qdrant_client && grep -n "get_context_headers" connection.py

Repository: qdrant/qdrant-client

Length of output: 249


🏁 Script executed:

rg "get_context_headers" --type py -B 3 -A 3

Repository: qdrant/qdrant-client

Length of output: 8140


🏁 Script executed:

rg "metadata" tests/test_tracing.py -B 2 -A 2 | head -50

Repository: qdrant/qdrant-client

Length of output: 1895


🏁 Script executed:

grep -n "metadata" qdrant_client/connection.py | head -20

Repository: qdrant/qdrant-client

Length of output: 1157


🏁 Script executed:

# Check if there's a test case for metadata conflicts (context headers + new_metadata with same key)
grep -A 20 "test.*metadata" tests/test_tracing.py | grep -E "new_metadata|conflict|duplicate" || echo "No conflict tests found"

Repository: qdrant/qdrant-client

Length of output: 87


🏁 Script executed:

# Check what new_metadata contains when passed to interceptors
grep -B 5 -A 10 "new_metadata" qdrant_client/connection.py | head -40

Repository: qdrant/qdrant-client

Length of output: 1292


Ensure context headers deterministically overwrite conflicting metadata keys in gRPC calls.

At lines 176–177 and 238–239, context headers are appended directly to the metadata list. If new_metadata or client_call_details.metadata already contains a key present in context headers (e.g., x-tracing-id), duplicate entries are sent to the server. Since gRPC transmits all duplicate values with undefined precedence, this creates ambiguous behavior.

The fix should remove existing metadata entries with conflicting keys before extending with context headers:

Proposed solution
-        for key, value in get_context_headers().items():
-            metadata.append((key, value))
+        context_headers = get_context_headers()
+        if context_headers:
+            context_keys = {key.lower() for key in context_headers}
+            metadata = [
+                (key, value) for key, value in metadata if key.lower() not in context_keys
+            ]
+            metadata.extend(context_headers.items())

Apply to both sync (lines 176–177) and async (lines 238–239) interceptors.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for key, value in get_context_headers().items():
metadata.append((key, value))
context_headers = get_context_headers()
if context_headers:
context_keys = {key.lower() for key in context_headers}
metadata = [
(key, value) for key, value in metadata if key.lower() not in context_keys
]
metadata.extend(context_headers.items())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qdrant_client/connection.py` around lines 176 - 177, The metadata list is
appended with get_context_headers() causing duplicate keys; update both the sync
interceptor block (where metadata is built from
new_metadata/client_call_details.metadata and appended with
get_context_headers()) and the async interceptor block to first remove any
existing entries whose key matches a context header key, then extend the
metadata with context headers; specifically, in the places that reference
metadata, new_metadata, client_call_details.metadata and get_context_headers(),
filter out entries with keys present in get_context_headers().keys() (preserving
order for remaining entries) before adding the context headers so context
headers deterministically overwrite conflicting keys.

Comment on lines +5 to +9
_context_headers: ContextVar[dict[str, str]] = ContextVar("_context_headers", default={})


def get_context_headers() -> dict[str, str]:
return _context_headers.get()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid mutable ContextVar default and returning mutable internal state.

Line 5 uses a mutable default dict, and Line 9 returns the internal object directly. External mutation can leak/taint header state across contexts.

🔧 Proposed fix
 from contextlib import asynccontextmanager, contextmanager
 from contextvars import ContextVar
-from typing import AsyncIterator, Iterator
+from typing import AsyncIterator, Iterator
 
-_context_headers: ContextVar[dict[str, str]] = ContextVar("_context_headers", default={})
+_context_headers: ContextVar[dict[str, str] | None] = ContextVar(
+    "_context_headers", default=None
+)
 
 
 def get_context_headers() -> dict[str, str]:
-    return _context_headers.get()
+    return dict(_context_headers.get() or {})
@@
 def headers(extra_headers: dict[str, str]) -> Iterator[None]:
-    current = _context_headers.get()
+    current = _context_headers.get() or {}
     merged = {**current, **extra_headers}
@@
 async def async_headers(extra_headers: dict[str, str]) -> AsyncIterator[None]:
-    current = _context_headers.get()
+    current = _context_headers.get() or {}
     merged = {**current, **extra_headers}

Also applies to: 14-16, 25-27

🧰 Tools
🪛 Ruff (0.15.5)

[warning] 5-5: Do not use mutable data structures for ContextVar defaults

(B039)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qdrant_client/context_headers.py` around lines 5 - 9, _context_headers
currently uses a mutable default dict and get_context_headers returns the
internal dict directly, risking cross-context mutation; change the ContextVar
declaration to use a default of None (or no default) and update all
setters/getters (e.g., get_context_headers, set_context_headers, and any
clear_context_headers) to treat the stored value as optional, always store
copies when calling _context_headers.set(...) and have get_context_headers
return a shallow copy (or an empty dict copy) rather than the internal object so
external mutation cannot taint the ContextVar across contexts; apply the same
copy-on-set/copy-on-get approach to the other related functions referenced in
the diff.

Comment thread qdrant_client/context_headers.py Outdated
@joein joein force-pushed the tracing-header branch from a2c57a1 to ee56bb0 Compare May 7, 2026 17:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
qdrant_client/context_headers.py (1)

7-11: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid shared mutable ContextVar state exposure.

ContextVar(..., default={}) plus returning _context_headers.get() directly allows external mutation of internal header state, which can leak/taint headers across contexts.

🔧 Proposed fix
-_context_headers: ContextVar[dict[str, str]] = ContextVar("_context_headers", default={})
+_context_headers: ContextVar[dict[str, str] | None] = ContextVar(
+    "_context_headers", default=None
+)
@@
 def get_context_headers() -> dict[str, str]:
-    return _context_headers.get()
+    return dict(_context_headers.get() or {})
@@
 def headers(extra_headers: dict[str, str]) -> Iterator[None]:
-    current = _context_headers.get()
+    current = _context_headers.get() or {}
@@
 async def async_headers(extra_headers: dict[str, str]) -> AsyncIterator[None]:
-    current = _context_headers.get()
+    current = _context_headers.get() or {}
#!/bin/bash
# Verify mutable ContextVar default and direct state exposure in this module
rg -n 'ContextVar\("_context_headers", default=\{\}\)|return _context_headers\.get\(\)' qdrant_client/context_headers.py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/context_headers.py` around lines 7 - 11, The ContextVar
_context_headers currently uses a mutable default {} and get_context_headers
returns the internal dict directly, allowing external mutation and cross-context
leakage; change _context_headers to avoid a shared mutable default (e.g., no
mutable literal default or use None) and update get_context_headers to return a
shallow copy of the stored mapping (or construct a new dict from the stored
value) so callers cannot mutate the internal state; reference _context_headers
and get_context_headers when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@qdrant_client/context_headers.py`:
- Around line 7-11: The ContextVar _context_headers currently uses a mutable
default {} and get_context_headers returns the internal dict directly, allowing
external mutation and cross-context leakage; change _context_headers to avoid a
shared mutable default (e.g., no mutable literal default or use None) and update
get_context_headers to return a shallow copy of the stored mapping (or construct
a new dict from the stored value) so callers cannot mutate the internal state;
reference _context_headers and get_context_headers when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 43bc6346-ae00-4b98-9c12-4acc7af67ea5

📥 Commits

Reviewing files that changed from the base of the PR and between a2c57a1 and ee56bb0.

📒 Files selected for processing (6)
  • qdrant_client/async_qdrant_remote.py
  • qdrant_client/connection.py
  • qdrant_client/context_headers.py
  • qdrant_client/qdrant_remote.py
  • tests/test_tracing.py
  • tools/async_client_generator/remote_generator.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tools/async_client_generator/remote_generator.py
  • qdrant_client/connection.py
  • qdrant_client/async_qdrant_remote.py

@joein joein merged commit ddfdcf4 into dev May 7, 2026
12 checks passed
joein added a commit that referenced this pull request May 11, 2026
* implement thread-local header overwrite for setting headers for individual requests

* mypy

* fix: remove redundant import in init, remove redundant import in qdrant remote

---------

Co-authored-by: George Panchuk <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants