-
Notifications
You must be signed in to change notification settings - Fork 423
chore: Improving OpAmp telemetry #2594
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jordan-simonovski
wants to merge
3
commits into
main
Choose a base branch
from
jordansimonovski/opamp-telemetry-improvements
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| --- | ||
| "@hyperdx/api": patch | ||
| --- | ||
|
|
||
| Improve API telemetry quality. The OpAMP message handler span is now a wide event | ||
| carrying agent correlation and self-description context (instance UID, sequence | ||
| number and gap, raw + decoded capability flags, new-vs-existing, service name and | ||
| version, OS type, host arch, health/last-error/uptime, remote config apply | ||
| status/error, last-applied and sent config hashes for drift detection, effective | ||
| config presence/size, teams count, request/response sizes). A new | ||
| `hyperdx.opamp.remote_config_applications` counter tracks whether pushed configs | ||
| actually applied on agents. The shared error middleware now recognizes body-parser | ||
| errors: client disconnects (`request.aborted` / `ECONNABORTED`) are classified as | ||
| operational, logged at debug instead of error, and kept out of error tracking, and | ||
| `hyperdx.api.errors` gains a bounded `error_type` dimension so aborts, oversized | ||
| bodies, and malformed payloads are distinguishable. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,13 +14,25 @@ aligned with the | |
| `setBusinessContext(...)` so `hyperdx.team.id` / `user.id` end up on | ||
| the trace. Auth middleware already does this for HTTP requests; background | ||
| jobs and other entry points must do it themselves. | ||
| 2. **If something is worth a log, it's usually worth a metric.** When a log line | ||
| marks a countable event (an error, a skip, a fired alert, a query), emit a | ||
| counter or histogram alongside it. | ||
| 3. **Spans and metric attributes must be low-cardinality.** Never put raw | ||
| queries, user input, IDs, or error messages into a span _name_ or a metric | ||
| _attribute key/value enum_. IDs belong in span _attributes_, not names. | ||
| 4. **Use the shared helpers** in | ||
| 2. **If something is worth a log, put it on the span first.** The active span is | ||
| the wide event for the current unit of work — attach the fact there as an | ||
| attribute. If it also marks a countable event worth aggregating (an error, a | ||
| skip, a fired alert, a query), emit a counter or histogram alongside it. | ||
| 3. **Cardinality belongs on span _attributes_, not span _names_ or _metrics_.** | ||
| Never put raw queries, user input, IDs, or error messages into a span _name_ | ||
| or a metric _attribute key/value_ — those must stay low-cardinality. Span | ||
| _attributes_ are the opposite: enrich them freely with high-cardinality | ||
| context (IDs, sizes, statuses), because that is what makes a trace queryable | ||
| after the fact. | ||
| 4. **Favor wide events for our own code — metrics stay first-class.** For the | ||
| instrumentation _in this repo_ we lean on richly-attributed spans: before | ||
| adding an instrument, ask whether the value belongs on the span for the work | ||
| already in flight, and put point-in-time / gauge-style values (sizes, counts, | ||
| depths, current state) there rather than in a gauge. This is an internal | ||
| 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 | ||
| [`packages/api/src/utils/instrumentation.ts`](../packages/api/src/utils/instrumentation.ts). | ||
| Don't hand-roll tracer/meter lifecycle. | ||
|
|
||
|
|
@@ -103,11 +115,60 @@ Standard attribute keys: | |
| | `hyperdx.<domain>.<field>` | Domain IDs, e.g. `hyperdx.alert.id`, `hyperdx.source.id`. | | ||
| | `feature_flag.<name>` | Evaluated feature/config flag state. | | ||
|
|
||
| ### Wide events over gauges | ||
|
|
||
| > **Scope:** this is HyperDX's _internal_ instrumentation philosophy for the | ||
| > code in this repo — not a recommendation that users pick wide events over | ||
| > metrics. Metrics are a first-class HyperDX signal that many operators rely on; | ||
| > nothing here discourages them. What follows is simply how _we_ prefer to | ||
| > instrument our own services. | ||
|
|
||
| We instrument in the spirit of | ||
| [wide events](https://boristane.com/blog/observability-wide-events-101/): one | ||
| richly-attributed span per unit of work beats a scatter of pre-aggregated | ||
| metrics. Pre-aggregation only answers the questions you thought to ask in | ||
| advance; a wide event lets you slice by any dimension _after_ the incident — | ||
| "aborted uploads, but only from agents on this collector version, over 5 MB" is | ||
| a trace query, not a metric you had the foresight to define. | ||
|
|
||
| The practical consequence for our code: **default away from gauges.** A gauge is | ||
| a point-in-time sample of a value, and that value almost always belongs to some | ||
| unit of work — a request body size, a batch length, a queue depth at dequeue, a | ||
| team count used to build a config. Prefer putting it on that operation's span as | ||
| an attribute. There it keeps its correlations (which agent, which team, which | ||
| outcome) and stays queryable across every other attribute on the event; a gauge | ||
| sheds all of that the moment it's recorded. | ||
|
|
||
| ```ts | ||
| // Don't: a bare gauge, stripped of the context that makes it useful. | ||
| // meter.createObservableGauge('opamp.request.body_size')... | ||
|
|
||
| // Do: attach the point-in-time value to the span for the work in flight, where | ||
| // it can be sliced by agent, team, outcome, or any other span attribute. | ||
| span.setAttribute('opamp.request.body_size_bytes', req.body.length); | ||
| span.setAttribute('opamp.teams.count', teams.length); | ||
| ``` | ||
|
|
||
| Metrics still earn their place for **aggregate signals that must exist | ||
| independently of any single event** — availability/latency SLIs, error rates, | ||
| alert thresholds — because those stay correct under trace sampling and are cheap | ||
| to alert on. That's the counters and histograms below. What we skip is the | ||
| gauge: if a value is worth recording at a point in time, the span is where it | ||
| belongs. | ||
|
|
||
| The rare exception is an ambient value with no owning operation (a pool size or | ||
| queue depth sampled by a background poller). Even then, prefer emitting a | ||
| 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. | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I love these changes |
||
| ### Metrics | ||
|
|
||
| When you write a log line that marks a countable event, add a metric too. | ||
| Counters for occurrences, histograms for durations/sizes. Prefer counters and | ||
| histograms over gauges. | ||
| Counters for occurrences, histograms for durations/sizes. For our own code we | ||
| favor span attributes over gauges (see | ||
| [Wide events over gauges](#wide-events-over-gauges)), but metrics themselves are | ||
| first-class — reach for them for aggregate signals, alerts, and SLOs. | ||
|
|
||
| ```ts | ||
| import { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import type { Request, Response } from 'express'; | ||
|
|
||
| // --- mocks (hoisted; names must be prefixed with `mock`) --- | ||
| const mockCounterAdd = jest.fn(); | ||
| const mockRecordException = jest.fn(); | ||
| const mockSetAttribute = jest.fn(); | ||
| const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; | ||
|
|
||
| jest.mock('@hyperdx/node-opentelemetry', () => ({ | ||
| recordException: (...args: unknown[]) => mockRecordException(...args), | ||
| })); | ||
| jest.mock('@opentelemetry/api', () => ({ | ||
| trace: { getActiveSpan: () => ({ setAttribute: mockSetAttribute }) }, | ||
| })); | ||
| jest.mock('@/config', () => ({ IS_PROD: false })); | ||
| jest.mock('@/utils/instrumentation', () => ({ | ||
| getCounter: () => ({ add: (...args: unknown[]) => mockCounterAdd(...args) }), | ||
| })); | ||
| jest.mock('@/utils/logger', () => ({ | ||
| __esModule: true, | ||
| default: mockLogger, | ||
| })); | ||
|
|
||
| import { appErrorHandler } from '@/middleware/error'; | ||
| import { Api404Error } from '@/utils/errors'; | ||
|
|
||
| const invoke = (err: unknown): Response => { | ||
| const res = { | ||
| headersSent: false, | ||
| status: jest.fn().mockReturnThis(), | ||
| json: jest.fn(), | ||
| } as unknown as Response; | ||
| appErrorHandler(err as any, {} as Request, res, jest.fn()); | ||
| return res; | ||
| }; | ||
|
|
||
| const lastCounterAttrs = () => | ||
| mockCounterAdd.mock.calls[mockCounterAdd.mock.calls.length - 1][1]; | ||
|
|
||
| describe('appErrorHandler', () => { | ||
| beforeEach(() => jest.clearAllMocks()); | ||
|
|
||
| describe('client disconnect (body-parser request.aborted)', () => { | ||
| const abortErr = Object.assign(new Error('request aborted'), { | ||
| type: 'request.aborted', | ||
| statusCode: 400, | ||
| received: 5, | ||
| expected: 10, | ||
| }); | ||
|
|
||
| it('classifies as operational and logs at debug, not error', () => { | ||
| invoke(abortErr); | ||
| expect(mockLogger.debug).toHaveBeenCalledTimes(1); | ||
| expect(mockLogger.error).not.toHaveBeenCalled(); | ||
| expect(mockLogger.warn).not.toHaveBeenCalled(); | ||
| expect(lastCounterAttrs()).toMatchObject({ | ||
| operational: true, | ||
| error_type: 'request.aborted', | ||
| }); | ||
| }); | ||
|
|
||
| it('skips recordException (benign background noise)', () => { | ||
| invoke(abortErr); | ||
| expect(mockRecordException).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('records how far the upload got on the span', () => { | ||
| invoke(abortErr); | ||
| expect(mockSetAttribute).toHaveBeenCalledWith( | ||
| 'http.request.received_bytes', | ||
| 5, | ||
| ); | ||
| expect(mockSetAttribute).toHaveBeenCalledWith( | ||
| 'http.request.expected_bytes', | ||
| 10, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('outbound ECONNABORTED (e.g. axios timeout) is NOT a disconnect', () => { | ||
| // No body-parser `type` — must not be silently downgraded. | ||
| const timeoutErr = Object.assign(new Error('timeout of 1000ms exceeded'), { | ||
| code: 'ECONNABORTED', | ||
| }); | ||
|
|
||
| it('stays a non-operational error: logged at error, recorded', () => { | ||
| invoke(timeoutErr); | ||
| expect(mockLogger.error).toHaveBeenCalledTimes(1); | ||
| expect(mockLogger.debug).not.toHaveBeenCalled(); | ||
| expect(mockRecordException).toHaveBeenCalledTimes(1); | ||
| expect(lastCounterAttrs()).toMatchObject({ operational: false }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('error_type label boundedness', () => { | ||
| it('uses the bounded class name, not the interpolated BaseError message', () => { | ||
| invoke(new Api404Error(`Team not found for user ${'abc-123'}`)); | ||
| // Operational (Api4xx) -> warn. The label is the bounded class name — | ||
| // NOT the interpolated message. BaseError's setPrototypeOf collapses | ||
| // subclasses to "BaseError"; granularity comes from status_code (404). | ||
| expect(mockLogger.warn).toHaveBeenCalledTimes(1); | ||
| expect(lastCounterAttrs()).toMatchObject({ | ||
| error_type: 'BaseError', | ||
| status_code: 404, | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good stuff