Skip to content

Stamp the deployment (local vs hosted) into the client telemetry headers - #551

Open
mocha06 wants to merge 3 commits into
devfrom
rc-dev/feat/telemetry-deployment-axis
Open

Stamp the deployment (local vs hosted) into the client telemetry headers#551
mocha06 wants to merge 3 commits into
devfrom
rc-dev/feat/telemetry-deployment-axis

Conversation

@mocha06

@mocha06 mocha06 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Motivation

telemetry_headers identifies the client by product, version, and surface, so an outbound API request carries User-Agent: pipefy-sdk/<version> (mcp) plus the X-Client-Name / X-Client-Version pair. The surface says which entry point ran (mcp, cli, sdk) but not where it ran: a stdio MCP server on a user's own machine and a hosted remote-profile server emit byte-identical headers. Anything aggregating those fields can count MCP traffic and break it down by version, but cannot separate the two deployments — so there is no read at all on hosted adoption versus local installs.

The fact was already resolved in-process and then dropped. McpRuntime.__init__ computes self.is_remote = settings.mcp.profile == "remote" on the line directly above the PipefyEngine.build(settings.pipefy, surface="mcp") call, and still passed the bare constant.

Outcome

A second axis beside ClientSurface, orthogonal to it — the surface says which entry point, the deployment says where that entry point runs:

# packages/infra/src/pipefy_infra/telemetry.py
ClientDeployment = Literal["local", "hosted"]

telemetry_user_agent takes an optional deployment, and with both a surface and a deployment it emits pipefy-sdk/<version> (<surface>; <deployment>). telemetry_headers gains X-Client-Deployment, matching the intent of the X-Client-Name / X-Client-Version pair it already sends: parsed fields, so a consumer never has to match on the User-Agent string to group traffic.

The SDK forwards a keyword-only deployment: ClientDeployment | None = None through build_endpoints (the single site that calls telemetry_headers), build_executors, PipefyEngine.build, and PipefyClient.__init__, and re-exports ClientDeployment beside ClientSurface. The MCP composition root passes deployment="hosted" if self.is_remote else "local".

Deployment is derived, never configured

#336 established that the surface is stamped programmatically at each composition root, is never read from env or TOML, and cannot be forged by a caller. The deployment keeps that property: it is derived from the already-validated settings.mcp.profile, and no PIPEFY_*_DEPLOYMENT knob is added. test_no_env_var_can_forge_the_deployment fails if such a setting is ever introduced.

Both sides are labelled explicitly (local and hosted) rather than marking only hosted, so a bare (mcp) unambiguously reads as "client older than this change" and never as "local". The one-time cost is that the existing (mcp) series is renamed rather than continued.

A local operator can set PIPEFY_MCP_PROFILE=remote and self-report as hosted, but doing so also demands a resource-server URL and an inbound issuer, so it is not a casual mislabel.

Behavior boundaries

deployment defaults to None at every seam, so every path that does not pass one is byte-identical to before:

  • The CLI keeps plain (cli) and sends exactly three client headers. A CLI is local by definition, and splitting that series buys nothing.
  • Direct SDK use keeps plain (sdk), three headers.
  • auth_telemetry_headers is untouched — the IdP path has neither a surface nor a deployment.

Out of scope, unchanged: the S3 attachment upload/download in packages/sdk/src/pipefy_sdk/services/attachment_service.py (not a Pipefy API host, so it does not belong in these counts) and the iPaaS gateway in packages/mcp/src/pipefy_mcp/core/ipaas_gateway.py, which sends only Authorization. Neither carries telemetry headers today and neither gains them here.

Docs touched

CHANGELOG.md (Unreleased → Added) documents the header trio and the two axes. No docs/ page or skill describes the client headers, so nothing else needed updating; the module and function docstrings in pipefy_infra.telemetry carry the two-axis contract. No MCP tool, CLI command, or tool count changed, so docs/parity.md, the README counts, the CLI help golden, and the remote seed are untouched.

Testing

Gates (all run locally, from the directory CI runs them in)

Gate Result
uv run pytest -m "not integration" 4013 passed, 5 skipped, 34 deselected
uv run ruff check packages/*/src All checks passed
uv run ruff format --check packages/*/src 212 files already formatted
cd packages/mcp && uv run lint-imports Contracts: 2 kept, 0 broken
uv run python scripts/bump_version.py verify exit 0
uv run python .github/workflows/scripts/lint_skill_refs.py passed (16 files)

Bytes on the wire

Unit tests pin the strings, but the contract is what the sink receives. PIPEFY_BASE_URL pointed at a local HTTP sink, driving the real composition root (McpRuntime.for_profile → session → a facade call) and then the CLI/SDK surfaces:

# Scenario Expected Observed Verdict
1 MCP, default profile (mcp; local) + X-Client-Deployment: local pipefy-sdk/0.4.0-beta.1 (mcp; local), X-Client-Name: mcp, X-Client-Deployment: local pass
2 MCP, --profile remote (mcp; hosted) + X-Client-Deployment: hosted pipefy-sdk/0.4.0-beta.1 (mcp; hosted), X-Client-Name: mcp, X-Client-Deployment: hosted pass
3 CLI surface (cli), no deployment header pipefy-sdk/0.4.0-beta.1 (cli), X-Client-Name: cli, X-Client-Deployment absent pass
4 Direct SDK use (sdk), no deployment header pipefy-sdk/0.4.0-beta.1 (sdk), X-Client-Name: sdk, X-Client-Deployment absent pass

Unit coverage added

  • packages/sdk/tests/test_telemetry.py: the (mcp; hosted) / (mcp; local) user-agent cases, the four-key header dict (exact-length, both deployments), and an explicit case that no deployment keeps the three-key dict. The pre-existing exact-length three-key cases are unchanged.
  • packages/mcp/tests/core/test_runtime.py: the built endpoints (all three) carry hosted under profile="remote" and local under profile="local", plus the no-env-knob drift-guard.

Closes #550

mocha06 added 2 commits July 30, 2026 19:22
The surface says which entry point ran (mcp, cli, sdk) but not where it ran,
so a stdio MCP server on a user's machine and a hosted remote-profile server
emit byte-identical client headers and cannot be told apart server-side.

Add a second, orthogonal axis: ClientDeployment ("local" | "hosted"). With a
surface and a deployment, telemetry_user_agent emits
"<product>/<version> (<surface>; <deployment>)" and telemetry_headers adds a
parsed X-Client-Deployment beside the X-Client-Name / X-Client-Version pair,
so a consumer never matches on the User-Agent string to group traffic.

The MCP composition root derives the deployment from the already-validated
settings.mcp.profile ("remote" -> hosted, otherwise local), keeping the
property #336 established for the surface: stamped programmatically, never
read from env or TOML, unforgeable by a caller. No PIPEFY_*_DEPLOYMENT knob
is added, and a test fails if one ever is.

Both sides are labelled explicitly, so a bare "(mcp)" unambiguously reads as
a client older than this change rather than a local one; the one-time cost is
that the existing "(mcp)" series is renamed rather than continued.

deployment defaults to None everywhere, so the cli and sdk surfaces and the
OAuth path stay byte-identical: the CLI keeps plain "(cli)" and sends three
client headers, and auth_telemetry_headers is untouched since the IdP path has
neither a surface nor a deployment.

Closes #550
@mocha06 mocha06 self-assigned this Aug 1, 2026
The back-merge that stamped `## [0.4.0-beta.2]` did so above the `### Added`
list this entry sat in, so a line-based merge left it inside a section that
predates the change — claiming the deployment axis shipped in 0.4.0-beta.2.
Textually clean, factually wrong.

Unreleased is the correct home either way: an alpha deliberately does not
stamp the changelog, so `## [Unreleased]` accumulates across alpha.1..alpha.N
and the beta promotion stamps the whole set into one section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mocha06
mocha06 requested a review from adriannoes August 1, 2026 01:57

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks merge-ready. The second telemetry axis lands where it should: pure builders in pipefy_infra, keyword-only forwarding through the existing SDK seams, and a single stamp at McpRuntime derived from the already-resolved profile (remote to hosted, otherwise local). CLI, direct SDK, and the IdP path stay byte-identical when deployment is omitted, which matches the stated boundaries.

What worked well:

  • Orthogonal axes with explicit dual labelling so bare (mcp) stays the pre-axis signal, not an ambiguous local default.
  • Exact UA and header-dict pins plus the profile wiring tests (including the env-forge guard) lock the wire contract without inventing a config knob.
  • The Unreleased CHANGELOG placement after the dev back-merge is the right fix; the rename cost for the MCP series is disclosed there.

Nothing required before merge from this review. CI is green on the reviewed head.

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