@middleware.io/otel-extensions
v0.5.0
Published
Middleware OpenTelemetry extensions: exception.stack_details enrichment and vcs.* resource detection, using only public OpenTelemetry extension points.
Downloads
751
Readme
@middleware.io/otel-extensions
Project state, coverage matrix and open work: see CLAUDE.md.
Middleware's OpenTelemetry additions, implemented entirely against public
OpenTelemetry extension points — no SDK fork, no patching, no overrides.
Zero runtime dependencies; TypeScript sources with bundled type definitions.
The OpenTelemetry packages are peerDependencies and are referenced only as
types, so nothing from them is pulled in or bundled at runtime.
const {
vcsDetector,
ExceptionStackDetailsSpanProcessor,
} = require('@middleware.io/otel-extensions');
const sdk = new NodeSDK({
resourceDetectors: [envDetector, hostDetector, processDetector, vcsDetector],
spanProcessors: [new ExceptionStackDetailsSpanProcessor(), ...yourExporters],
});vcsDetector
A standard ResourceDetector. Adds:
vcs.commit_sha— fromMW_VCS_COMMIT_SHA, elseHEADresolved out of the local.gitdirectoryvcs.repository_url— fromMW_VCS_REPOSITORY_URL, elseremote.origin.urlfrom.git/config(with any.gitsuffix stripped)
The .git reading is done by parsing the files directly (src/git-metadata.ts)
— no git binary and no git library dependency. It handles ref indirection,
packed-refs, and the quoting/comment rules of git-config. Resolution is
cached at module scope, so the lookup runs once per process no matter how many
providers detect it.
Production images usually don't ship .git, so setting the two environment
variables from CI is the realistic path; the .git fallback mainly helps in
dev and staging.
ExceptionStackDetailsSpanProcessor
Adds exception.stack_details to recorded exception events — an expanded
form of exception.stacktrace. For each resolvable frame:
| key | |
|---|---|
| exception.file | absolute path |
| exception.line / exception.column_number | position |
| exception.function_name | or anonymous |
| exception.is_file_external | whether the frame is under node_modules |
| exception.function_body | source of the enclosing function — own code only |
| exception.start_line / exception.end_line | 1-based, inclusive file lines of that body |
| exception.function_truncated | present when the function exceeded maxFunctionLines |
exception.language matches the key the Python SDK emits, and becomes
typescript when a map resolves back to TypeScript sources.
Compiled and bundled services
For a TypeScript or bundled service, the file that runs isn't the file anyone
wrote: line numbers are shifted, types are erased, and syntax has been
downlevelled. dist/server.js:33 might be src/server.ts:48, with
order.status ?? OrderStatus.Pending showing up as a three-way ternary over a
hoisted var _a.
When a source map yields both the original position and its text, the standard keys above are populated from the original source, and the compiled position is preserved alongside:
| key | |
|---|---|
| exception.generated_file | the compiled file the frame was reported in |
| exception.generated_line / exception.generated_column_number | 1-based |
| exception.generated_function_body | enclosing function in the compiled file |
| exception.generated_start_line / exception.generated_end_line | 1-based, inclusive |
So a consumer that only knows the standard keys shows source instead of build output, with no change on its side.
How much is recoverable depends on the build, and degrades in tiers:
- map with
sourcesContent(esbuild and webpack defaults;tscwithinlineSources) → original position and text, so the keys are promoted - map without it, but the original files are still in the image (any single-stage Docker build) → read off disk, same result
- map with neither → the position is reported as
exception.original_file/exception.original_line, but not promoted.function_bodyhas to stay consistent withfileandstart_line, and promoting a position whose text we don't have would mislabel every line of the body - no map → nothing added
Tier 3 is the common tsc case, since sourceMap: true does not imply
sourcesContent — that needs inlineSources: true, a one-line change that
moves a build from tier 3 to tier 1.
Source maps are read only for frames whose source is captured, so the cost
stays proportional to the value, and decoded maps are cached per file.
Set resolveSourceMaps: false to skip it entirely.
What gets a body
Source is captured for the application's own frames only. Dependency frames
(node_modules) carry location alone: their bodies are rarely actionable and,
being the bulk of a typical stack, they crowd out the code a reader can act
on. Set includeExternalSource: true to capture them anyway.
function_body is the enclosing function — signature through closing brace
— not a fixed window, so the reader sees the parameters and guard clauses
rather than an arbitrary slice. Detection is brace matching (string- and
comment-aware), not parsing; when it can't find a function, or the function
is longer than maxFunctionLines, it falls back to contextLines either side
of the throw site and sets exception.function_truncated.
Mapping the body back to the file
start_line/end_line are 1-based and inclusive, and the body is always
contiguous, so:
file line N === body line (N - start_line)The throwing line is exception.line, so it sits at
body[exception.line - start_line]. (Before 0.3.0 start_line was a 0-based
slice index reported as a line number, so every line mapped one row off.)
Frames that don't resolve to a real file on disk (node:internal/...,
<anonymous>) are skipped, so an error thrown entirely inside Node core
produces no stack_details at all.
Set MW_RECORD_EXCEPTION_SOURCE=false to keep the frame metadata but skip
reading source files from disk.
Supported OpenTelemetry versions, and onEnding vs onEnd
Works on OpenTelemetry 1.26+ and 2.x. The OTel packages are optional,
type-only peerDependencies, so buildExceptionStackDetails can also be used
on its own with none of them installed.
Enrichment has to land before an exporting processor serializes the span, and the available hook differs by version — so it is attempted from both, and is idempotent:
2.x calls every processor's
onEndingbefore anyonEnd, so enrichment is independent of registration order. The lateronEndfinds the work done and returns.1.x has no
onEndingat all — it is absent fromMultiSpanProcessorand from theSpanProcessorinterface — soonEndis the only hook. That path is order-dependent: register this processor ahead of the exporting one, or the span is serialized before enrichment runs and the attribute is silently lost.NodeSDKregistersspanProcessorsin array order, so listing this one first is enough:spanProcessors: [ new ExceptionStackDetailsSpanProcessor(), new BatchSpanProcessor(exporter), ]
Both paths are covered by tests that run against a real 1.26 SDK installed under an npm alias, including one asserting the ordering hazard above.
Worth knowing when testing this yourself: an in-memory exporter holds spans by reference and will make a late-mutation bug look like it works.
Options
Attributes written from a processor bypass the span's
attributeValueLengthLimit, so this caps itself. All options are set per
instance:
new ExceptionStackDetailsSpanProcessor({
maxFrames: 20, // frames captured
maxAttributeLength: 131072, // 128 KiB serialized
maxFunctionLines: 80, // largest function captured whole
contextLines: 10, // window either side when falling back
includeExternalSource: false, // capture node_modules bodies too
resolveSourceMaps: true // map generated positions back to source
});When the serialized value exceeds the budget, the deepest frames are dropped first — the throw site is frame 0, so the most useful context is kept.
Caveat
The processor parses the recorded exception.stacktrace string, not the live
Error. If OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT is set, that string is already
truncated and tail frames are lost. The default is unlimited.
Development
npm install
npm run compile # tsc -> build/
npm test # mocha + ts-node against src/
npm run typecheck # type-checks src/ and test/ togetherprepublishOnly runs clean + compile + typecheck + test, so a publish cannot
ship a build that doesn't compile or pass its tests.
Node-only by design: it reads the .git directory and application source off
disk, neither of which a browser can do. Browser no-op stubs exist on the
fork-approach-archive branch if a RUM build ever needs them.
Releasing
.github/workflows/ holds the automation. It does nothing while this
directory lives inside the opentelemetry-js fork — GitHub only reads
workflows from a repository's root, and the fork's root belongs to upstream
(21 workflow files, which we deliberately don't touch so rebases stay clean).
The moment this directory becomes its own repository, both workflows go live
with no changes.
ci.yml— every push tomasterand every PR: compile, typecheck and test on Node 18/20/22, plus a single-arch image build that is loaded but not pushed, asserting the bundle still contains the extensions package and that no Middleware code has leaked into@opentelemetry.release.yml— triggered by av*tag:npm version minor -m 'release %s' git push origin master --follow-tagsIt refuses to release if the tag and
package.jsonversion disagree, publishes to npm (gated byprepublishOnly), waits for the registry to actually serve the new version, pins the init container to that exact version, builds and pushes the multi-arch image, and then runs the pushed image on both architectures to confirm it really contains the released package.
Two details worth knowing, both learned the hard way:
- npmjs.org's public read path lags a successful publish — sometimes by
minutes. The image build is a plain
npm install, so it will 404 on a version that definitely exists. Hence the wait-for-registry step. - The image is pushed straight from buildx. A
docker pushof a locally loaded manifest list can flatten it to a single architecture.
Secrets required: NPM_TOKEN. For GHCR, the default GITHUB_TOKEN may not
have write access because the image sits under a different repository's
package path (opentelemetry-operator/autoinstrumentation-node) — either
grant this repo write on that package, or set a GHCR_TOKEN PAT.
To rebuild or retag an image without republishing to npm, run the workflow
manually from the Actions tab and pass a version plus an optional extra image
tag (e.g. 0.64b0-opsai-record-exception-v4).
