Skip to content

chore: Improving OpAmp telemetry#2594

Open
jordan-simonovski wants to merge 3 commits into
mainfrom
jordansimonovski/opamp-telemetry-improvements
Open

chore: Improving OpAmp telemetry#2594
jordan-simonovski wants to merge 3 commits into
mainfrom
jordansimonovski/opamp-telemetry-improvements

Conversation

@jordan-simonovski

Copy link
Copy Markdown
Contributor

Why

The OpAMP message handler is our own code in an observability product, but its span was effectively blind: it carried a single attribute (accepts_remote_config), while agent identity, capabilities, health, and config state lived only in debug logs or aggregate counters. When an agent misbehaves or a pushed config fails to apply, there was no way to pin the trace to a specific agent or slice by the dimensions that matter (collector version, OS, health). Separately, client disconnects mid-upload (request aborted) were being logged at error and counted as non-operational failures, inflating our error signal with benign background noise.

This PR turns the OpAMP span into a wide event and teaches the shared error middleware to classify body-parser errors correctly.

What changed

OpAMP handler span → wide event (opamp/controllers/opampController.ts, services/agentService.ts)

  • Agent self-description: service_name, service_version, os_type, host_arch (flattened from AgentDescription)
  • Capabilities: decoded capability_flags alongside the raw bitmask
  • Health: healthy, health_last_error, health_status, uptime_ms
  • Config drift: last_remote_config_hash (what the agent applied) vs remote_config.hash (what we sent)
  • Effective config: reports_effective_config, effective_config.size_bytes
  • Sequencing: sequence_num + sequence_gap (flags restarts / dropped messages)
  • New hyperdx.opamp.remote_config_applications counter — fleet-health signal for whether pushed configs actually apply (bounded status enum)

New pure helpers (opamp/utils/agentTelemetry.ts, unit-tested)

  • toSafeNumber — coerces protobuf 64-bit fields (Long) to numbers. This fixes a latent bug: sequence_num was guarded by typeof === 'number', which never matched a Long, so it was silently never recorded.
  • decodeAgentCapabilities, getAgentAttribute

Error middleware (middleware/error.ts)

  • Recognizes body-parser errors; client disconnects (request.aborted / ECONNABORTED) are now classified operational → logged at debug, skipped from recordException, and kept out of the non-operational error signal
  • Adds a bounded error_type dimension to hyperdx.api.errors so aborts, oversized bodies, and malformed payloads are distinguishable
  • Records http.request.received/expected_bytes on aborts

Docs (agent_docs/observability.md, AGENTS.md)

  • Documents the wide-events instrumentation philosophy, explicitly scoped as our internal preference — metrics remain first-class for the OSS users who depend on them

Testing

  • agentTelemetry unit suite: 11/11 passing
  • Existing opampController test: 5/5 passing
  • tsc --noEmit clean, eslint --quiet (CI gate) clean
  • Changeset included (@hyperdx/api patch)

Notes / limitations

  • uptime_ms is millisecond-approximate (nanosecond epoch via JS number) — fine for "how long up," not precise timing
  • Health only appears when the agent reports the health field (not persisted on the agent record)
  • The rawParser abort span is auto-instrumented and still shows ERROR status; we fixed the logs, metric classification, and dimensions, not the auto-span itself

Improving some of our telemetry to make it easier to debug issues
@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b178b96

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Jul 7, 2026 8:05pm
hyperdx-storybook Ready Ready Preview, Comment Jul 7, 2026 8:05pm

Request Review

@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 406 production lines changed (Tier 2 max: < 250)

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 6
  • Production lines changed: 406 (+ 349 in test files, excluded from tier calculation)
  • Branch: jordansimonovski/opamp-telemetry-improvements
  • Author: jordan-simonovski

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR turns the OpAMP message-handler span into a wide event by adding ~15 new attributes (agent identity, health, capability flags, config drift, request/response sizes) and fixes a latent bug where 64-bit protobuf fields (sequenceNum, capabilities) were silently dropped because typeof === 'number' never matched a long.js Long. The error middleware is updated to correctly classify body-parser client disconnects as operational, keeping them out of the error signal and off error tracking.

  • agentTelemetry.ts: New pure helpers (toSafeNumber, decodeAgentCapabilities, remoteConfigStatusName, truncateAttr, getAgentAttribute) are well-tested and defend against cardinality blowup on an unauthenticated endpoint via allowlists, string truncation, and bounded enum mappings.
  • agentService.ts: hyperdx.opamp.remote_config_applications counter fires only on genuine status transitions (not every heartbeat), fixing the inflation concern raised in the earlier review.
  • error.ts: Client-disconnect classification is keyed strictly on type === 'request.aborted' rather than code === 'ECONNABORTED', preventing outbound-timeout errors from being silently downgraded — covered by an explicit regression test.

Confidence Score: 5/5

Safe to merge — changes are additive telemetry enrichment with no mutations to business logic, all new helpers are pure and covered by 11+ unit tests, and the error-middleware update is a classification-only change with a dedicated non-regression test.

Every changed code path either adds new span attributes (additive, no side effects on request handling), fixes a silent data-loss bug in counter collection (Long coercion), or reclassifies a known benign error class. Test coverage is thorough and explicitly guards the prior-review concerns. No mutations to request routing, auth, or data persistence.

No files require special attention. The pre-commit knip addition will add latency to every commit for all contributors, but this is intentional and documented.

Important Files Changed

Filename Overview
packages/api/src/opamp/utils/agentTelemetry.ts New pure-helper module with well-tested utilities: Long→number coercion, capability bitmask decoder, bounded status name mapper, string truncation guard, and attribute extractor. All helpers use allowlists or explicit bounds to prevent cardinality blowup on an unauthenticated endpoint.
packages/api/src/opamp/controllers/opampController.ts Span enriched with ~15 new attributes covering agent identity, health, config drift, capability flags, and request/response sizes. Long→number coercion fix applied via toSafeNumber. Attribute values from agent input are truncated before recording. Sequence gap and remote_config.sent logic are correct.
packages/api/src/opamp/services/agentService.ts Adds transition-based remote_config_applications counter and sequence gap + is_new span attributes. Counter correctly fires only on status changes (not every heartbeat) by capturing previousRemoteConfigStatus before upsert.
packages/api/src/middleware/error.ts Classifies body-parser request.aborted errors as operational (debug log, skip recordException) keyed strictly on the type field, not ECONNABORTED. Adds bounded error_type dimension to the counter. received/expected bytes recorded on the span for abort context.
packages/api/src/middleware/tests/error.test.ts New test suite covering abort classification, ECONNABORTED non-regression (outbound errors stay non-operational), and bounded error_type label. Specifically tests that ECONNABORTED without a body-parser type is NOT silently downgraded.
packages/api/src/opamp/utils/tests/agentTelemetry.test.ts Comprehensive unit tests for all five helper functions including falsy scalar preservation (0, false), Long-style objects, unknown/out-of-range values, and truncation sentinel.
packages/api/src/opamp/services/tests/agentService.test.ts New integration-style unit tests covering first-contact detection, sequence gap calculation, transition-only counter increments, and UNSET (enum 0) handling.
.husky/pre-commit Adds full-project npx knip --no-config-hints to pre-commit; mirrors the CI Knip workflow gate so unused-code issues surface before push. Adds commit latency for all contributors.
knip.json Adds .github/actions/** and .claude/** to the ignore list to suppress false-positive unused-code reports for non-TS CI and agent configuration files.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[OpAMP POST /v1/messages] --> B{Buffer check}
    B -->|yes| C[Set request.body_size_bytes]
    B --> D[decodeAgentToServer]
    C --> D
    D --> E[Set instance_uid, sequence_num]
    E --> F[Read health field]
    F -->|present| G[Set healthy, health_last_error,\nhealth_status, uptime_ms]
    F --> H[agentService.processAgentStatus]
    G --> H
    H --> I{isNewAgent?}
    I -->|yes| J[Set is_new=true\ncounter: status=new]
    I -->|no| K[Compute sequence_gap\nSet is_new=false\ncounter: status=updated]
    J --> L{remoteConfigStatus\ntransition?}
    K --> L
    L -->|yes| M[opamp.remote_config_applications\ncounter += 1]
    L --> N[Back to controller]
    M --> N
    N --> O[Set capabilities,\ncapability_flags]
    O --> P[Flatten AgentDescription\nservice_name, service_version,\nos_type, host_arch]
    P --> Q[Set remote_config_status,\nlast_remote_config_hash,\neffective_config attrs]
    Q --> R{acceptsRemoteConfig?}
    R -->|no| S[Set remote_config.sent=false]
    R -->|yes| T[getAllTeams\nbuildOtelCollectorConfig\ncreateRemoteConfig]
    T --> U[Set remote_config.sent=true\nremote_config.hash\nteams.count]
    S --> V[encodeServerToAgent]
    U --> V
    V --> W[Set response.size_bytes\nspan OK\ncounter: outcome=processed]

    subgraph Error Middleware
    X[Express error] --> Y{bodyParserErrorType?}
    Y -->|request.aborted| Z[clientDisconnect=true\noperational=true\nlog at debug\nskip recordException\nset received/expected bytes]
    Y -->|other/none| AA{isOperationalError?}
    AA -->|yes| AB[log at warn\nrecordException]
    AA -->|no| AC[log at error\nrecordException]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[OpAMP POST /v1/messages] --> B{Buffer check}
    B -->|yes| C[Set request.body_size_bytes]
    B --> D[decodeAgentToServer]
    C --> D
    D --> E[Set instance_uid, sequence_num]
    E --> F[Read health field]
    F -->|present| G[Set healthy, health_last_error,\nhealth_status, uptime_ms]
    F --> H[agentService.processAgentStatus]
    G --> H
    H --> I{isNewAgent?}
    I -->|yes| J[Set is_new=true\ncounter: status=new]
    I -->|no| K[Compute sequence_gap\nSet is_new=false\ncounter: status=updated]
    J --> L{remoteConfigStatus\ntransition?}
    K --> L
    L -->|yes| M[opamp.remote_config_applications\ncounter += 1]
    L --> N[Back to controller]
    M --> N
    N --> O[Set capabilities,\ncapability_flags]
    O --> P[Flatten AgentDescription\nservice_name, service_version,\nos_type, host_arch]
    P --> Q[Set remote_config_status,\nlast_remote_config_hash,\neffective_config attrs]
    Q --> R{acceptsRemoteConfig?}
    R -->|no| S[Set remote_config.sent=false]
    R -->|yes| T[getAllTeams\nbuildOtelCollectorConfig\ncreateRemoteConfig]
    T --> U[Set remote_config.sent=true\nremote_config.hash\nteams.count]
    S --> V[encodeServerToAgent]
    U --> V
    V --> W[Set response.size_bytes\nspan OK\ncounter: outcome=processed]

    subgraph Error Middleware
    X[Express error] --> Y{bodyParserErrorType?}
    Y -->|request.aborted| Z[clientDisconnect=true\noperational=true\nlog at debug\nskip recordException\nset received/expected bytes]
    Y -->|other/none| AA{isOperationalError?}
    AA -->|yes| AB[log at warn\nrecordException]
    AA -->|no| AC[log at error\nrecordException]
    end
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into jordansimonovsk..." | Re-trigger Greptile

Comment thread packages/api/src/middleware/error.ts Outdated
Comment thread packages/api/src/opamp/controllers/opampController.ts Outdated
Comment thread packages/api/src/opamp/controllers/opampController.ts
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. Nothing in this diff is a guaranteed happy-path crash, data-loss, auth-bypass, or injection vector. The items below are recommended hardening and test/maintainability follow-ups.

🟡 P2 — recommended

  • packages/api/src/opamp/controllers/opampController.ts:446instanceUid.toString('hex') is written to opamp.agent.instance_uid with no length cap, unlike its sibling agent-string attributes which all go through truncateAttr; on this unauthenticated endpoint a large protobuf bytes field becomes an oversized span attribute on every message.
    • Fix: Wrap the hex string in truncateAttr (or validate the byte length) before calling setAttribute.
    • security, adversarial
  • packages/api/src/opamp/controllers/opampController.ts:540lastRemoteConfigHash.toString('hex') is set on opamp.agent.last_remote_config_hash without truncation, so an agent-supplied bytes field of arbitrary length lands unbounded on the span.
    • Fix: Pass the hex value through truncateAttr (or enforce a max byte length) before setAttribute.
    • security, adversarial
  • packages/api/src/opamp/utils/agentTelemetry.ts:6AGENT_CAPABILITY_FLAGS and REMOTE_CONFIG_STATUS_NAMES are hardcoded copies of the opamp.proto enums that protobuf.ts already loads via root.lookupEnum, so new proto capability bits or statuses will silently drift from telemetry decoding.
    • Fix: Derive these tables from root.lookupEnum('opamp.AgentCapabilities') / root.lookupEnum('opamp.RemoteConfigStatuses'), or add a test asserting the literals match the proto enum values.
    • maintainability
  • packages/api/src/opamp/controllers/opampController.ts:435 — The entire new handler span-enrichment block (health attrs, capability_flags, description flattening, effective_config.size_bytes reduce, response size) has no handler-level test; existing opampController.test.ts only exercises buildOtelCollectorConfig.
    • Fix: Add a test that feeds a synthetic AgentToServer through the handler and asserts the resulting setAttribute calls.
    • testing
  • packages/api/src/middleware/error.ts:61 — Tests cover only request.aborted and an Api4xx error; the oversized-body / malformed-payload branch (type present but not a disconnect) that sets error_type and http.request_error.type while still calling recordException is unexercised.
    • Fix: Add a case with an error carrying type: 'entity.too.large' asserting the error_type label, span attribute, absence of received_bytes, and retained recordException.
    • testing
🔵 P3 nitpicks (8)
  • .changeset/opamp-telemetry-context.md:13 — The changeset states client disconnects (request.aborted / ECONNABORTED) are classified operational, but error.ts deliberately keys only on type === 'request.aborted' and explicitly excludes code === 'ECONNABORTED', so the durable release note contradicts the code.
    • Fix: Drop the ECONNABORTED reference from the changeset so it matches the implementation.
  • packages/api/src/middleware/error.ts:61error_type falls back to any error's generic .type property via bodyParserErrorType, which runs on every error through this app-wide middleware; a non-body-parser error carrying a high-cardinality string .type would widen the hyperdx.api.errors metric dimension.
    • Fix: Allowlist the known bounded body-parser type values before using .type as a label, falling back to the bounded class name otherwise.
  • packages/api/src/opamp/services/agentService.ts:114 — First contact (undefined prior status) counts as a transition, and because agentStore is in-memory, an API restart re-classifies every agent's next heartbeat as new and re-counts its current status, inflating hyperdx.opamp.remote_config_applications after deploys without an actual apply.
    • Fix: Skip counting when the prior status is unknown due to a first-contact/cold-store case, or document the restart skew.
  • packages/api/src/opamp/utils/agentTelemetry.ts:106getAgentAttribute returns intValue / doubleValue directly, bypassing the toSafeNumber Long-coercion the rest of the PR applies, so an int64 attribute would return a Long typed as number and be silently dropped by the OTel SDK.
    • Fix: Route the numeric branches through toSafeNumber for consistency with the PR's Long-safety handling.
  • packages/api/src/opamp/controllers/opampController.ts:613 — New span attributes mix conventions: opamp.request.body_size_bytes vs opamp.response.size_bytes, and flat (opamp.agent.remote_config_status) vs dotted (opamp.remote_config.hash, opamp.agent.effective_config.size_bytes) namespaces for the same concepts.
    • Fix: Pick one size-suffix and one namespace convention and apply them consistently.
  • packages/api/src/opamp/controllers/opampController.ts:418opampController.ts is now ~640 lines with a ~130-line inline enrichment block, exceeding the AGENTS.md 300-line guideline (already over before this diff).
    • Fix: Extract the span-enrichment into a pure agentTelemetry.ts helper so the handler stays decode → process → respond.
    • maintainability, project-standards
  • packages/api/src/opamp/controllers/opampController.ts:475 — The uptime_ms computation Math.max(0, Math.round(Date.now() - startNano / 1e6)) (correct precedence and clamp) has no test, so a future regrouping regression would pass silently.
    • Fix: Add a fake-timers test asserting a known uptime_ms and the clamp-to-zero for a future start time.
    • testing, correctness
  • packages/api/src/opamp/services/__tests__/agentService.test.ts:1 — The three new fully-mocked unit test files live under packages/api, which defines only ci:int/dev:int (Docker/.env.test required) and no ci:unit, so make ci-unit skips them and they run only in the heavyweight integration lane.
    • Fix: Add a ci:unit lane to packages/api (or relocate the mocked tests) so the fast unit run exercises them.
    • project-standards

Pre-existing (not introduced by this diff; not counted toward the verdict)

  • packages/api/src/opamp/models/agent.tsagentStore never evicts; a flood of unique agent-controlled instanceUids on the unauthenticated OpAMP endpoint grows the in-memory Map without bound (memory-exhaustion risk). Worth a follow-up ticket for LRU/TTL eviction.
  • packages/api/src/opamp/services/agentService.ts:131agentAcceptsRemoteConfig applies a raw JS bitwise & to agent.capabilities without toSafeNumber, diverging from the Long-safe path this PR adds elsewhere; a Long-valued capabilities field could read the bit unreliably. adversarial, correctness, kieran-typescript

Reviewers (10): correctness, security, reliability, kieran-typescript, adversarial, testing, maintainability, project-standards, agent-native, learnings-researcher.

Testing gaps: controller-level span enrichment and uptime_ms are only indirectly covered via extracted-helper unit tests; the error middleware's oversized-body / malformed-JSON error_type branches and the no-active-span path are unexercised; sequence-gap wraparound (agent restart) behavior is undocumented by any test.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 224 passed • 3 skipped • 1479s

Status Count
✅ Passed 224
❌ Failed 0
⚠️ Flaky 3
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

periodic wide event — a heartbeat span carrying the readings — over a raw gauge,
so the readings stay sliceable; fall back to a gauge only when no such event
exists.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I love these changes

engineering preference, not a rule against metrics — counters and histograms
remain first-class, feed alerts and SLOs, and many HyperDX deployments depend
heavily on them. See [Wide events over gauges](#wide-events-over-gauges).
5. **Use the shared helpers** in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

good stuff

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants