feat(observability): add @sourceloop/observability with OTEL-first bootstrap, profiles, and pluggable instrumentation#2502
feat(observability): add @sourceloop/observability with OTEL-first bootstrap, profiles, and pluggable instrumentation#2502samarpan-b wants to merge 5 commits intomasterfrom
Conversation
Remediation Agent Summary 📊
Suggested fixes (4)
Issues requiring manual fix (2)
🤖 Agent created PR #2503
|
There was a problem hiding this comment.
Pull request overview
Adds a new @sourceloop/observability package that bootstraps OpenTelemetry tracing early in service startup, with optional LoopBack integration (bindings + extension point) and a small helper API for spans/correlation/errors.
Changes:
- Introduces a new
packages/observabilityworkspace package (bootstrap/runtime, LoopBack component, profiles, config resolution/validation, tracing helpers). - Adds built-in OTLP-based profiles (
default,newrelic,signoz,datadog) and optional auto-instrumentation loading via optional peer deps. - Adds unit/integration tests and wires the new package into the monorepo workspace + lockfile.
Reviewed changes
Copilot reviewed 37 out of 38 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/observability/tsconfig.json | TypeScript build configuration for the new package |
| packages/observability/src/types.ts | Public types for config, profiles, runtime, and instrumentations |
| packages/observability/src/tracing.ts | withSpan / addSpanAttributes helpers |
| packages/observability/src/runtime.ts | Singleton runtime state management helpers |
| packages/observability/src/profiles/signoz.profile.ts | SigNoz OTLP defaults profile |
| packages/observability/src/profiles/registry.service.ts | LoopBack extension-point registry for profiles |
| packages/observability/src/profiles/otlp.profile.ts | Base OTLP profile: resource/sampler/exporter + auto-instrumentations |
| packages/observability/src/profiles/newrelic.profile.ts | New Relic OTLP defaults profile |
| packages/observability/src/profiles/keys.ts | LoopBack extension-point keys + binding template helper |
| packages/observability/src/profiles/instrumentations.ts | Optional peer-dep instrumentation loading + toggles |
| packages/observability/src/profiles/index.ts | Public exports for profiles |
| packages/observability/src/profiles/datadog.profile.ts | Datadog OTLP defaults profile |
| packages/observability/src/keys.ts | LoopBack binding keys for config/runtime/resolved config |
| packages/observability/src/index.ts | Package public API surface |
| packages/observability/src/errors.ts | recordException span helper |
| packages/observability/src/correlation.ts | Trace context propagation/correlation helpers |
| packages/observability/src/config/validate-config.ts | Startup validation (dependencies + coherent toggles) |
| packages/observability/src/config/resolve-config.ts | Env/bootstrap/DI config resolution logic |
| packages/observability/src/config/defaults.ts | Default resolved config + default instrumentation toggles |
| packages/observability/src/component.ts | Optional LoopBack component wiring + built-in profile bindings |
| packages/observability/src/bootstrap.ts | Core bootstrap/shutdown/runtime creation |
| packages/observability/src/tests/unit/validate-config.unit.ts | Unit test for config validation |
| packages/observability/src/tests/unit/resolve-config.unit.ts | Unit test for config resolution precedence |
| packages/observability/src/tests/unit/profile-defaults.unit.ts | Unit tests for built-in profile defaults |
| packages/observability/src/tests/unit/instrumentations.unit.ts | Unit tests for instrumentation loading/enabling |
| packages/observability/src/tests/unit/README.md | Unit test folder marker doc |
| packages/observability/src/tests/integration/runtime.integration.ts | Integration test for runtime initialization + helpers |
| packages/observability/src/tests/integration/README.md | Integration test folder marker doc |
| packages/observability/src/tests/acceptance/README.md | Acceptance test folder marker doc |
| packages/observability/package.json | New package manifest (deps/peers/scripts) |
| packages/observability/README.md | Package documentation (usage, resolution model, profiles) |
| packages/observability/.prettierrc | Prettier config for the new package |
| packages/observability/.prettierignore | Prettier ignore rules |
| packages/observability/.gitignore | Package-level ignore rules |
| packages/observability/.eslintrc.js | ESLint config (typed linting) |
| packages/observability/.eslintignore | ESLint ignore rules |
| package.json | Adds packages/observability/ to workspaces |
| package-lock.json | Lockfile updates for the new workspace package and dependencies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…plexity from #2502 (#2503) * fix: Commit 1 - Fully fix typescript:S1541 Commit 1 of SonarQube suggestions Fully fixed issues: - [typescript:S1541] AZ2I0JCED4ljEi2GS_-X: Function has a complexity of 13 which is greater than 10 authorized. - [typescript:S1541] AZ2I0JCED4ljEi2GS_-Y: Function has a complexity of 21 which is greater than 10 authorized. Generated by SonarQube Agent * fix: Commit 2 - Fully fix typescript:S1541, typescript:S134 Commit 2 of SonarQube suggestions Fully fixed issues: - [typescript:S1541] AZ2I0JE5D4ljEi2GS_-c: Function has a complexity of 14 which is greater than 10 authorized. - [typescript:S134] AZ2I0JE5D4ljEi2GS_-d: Refactor this code to not nest more than 3 if/for/while/switch/try statements. Generated by SonarQube Agent --------- Co-authored-by: sonarqube-agent[bot] <210722872+sonarqube-agent[bot]@users.noreply.github.com>
| } | ||
|
|
||
| initialize( | ||
| config: ResolvedObservabilityConfig, |
There was a problem hiding this comment.
I noticed that bootstrapObservability() in bootstrap.ts already calls profile.applyDefaults(resolvedConfig) before invoking profile.initialize(). Since this override calls this.applyDefaults(config) again before delegating to super, applyDefaults ends up running twice — headers get merged twice and vendor.apm gets set twice. The same pattern exists in DatadogObservabilityProfile and SignozObservabilityProfile.
Removing the initialize() override here (and in Datadog/Signoz) should be enough — BaseOtlpObservabilityProfile.initialize handles initialization fully, and applyDefaults is already called by the bootstrap flow.
// applyDefaults() is already called by bootstrapObservability() before initialize() is invoked.
// The base class implementation is sufficient — no need to override initialize() here.
export class NewRelicObservabilityProfile extends BaseOtlpObservabilityProfile {
name: ObservabilityProfileName = 'newrelic';
applyDefaults(config: ResolvedObservabilityConfig): ResolvedObservabilityConfig {
const licenseKey = process.env.NEW_RELIC_LICENSE_KEY?.trim();
return {
...config,
otlpEndpoint:
config.otlpEndpoint ??
(config.exporterProtocol === 'grpc'
? 'https://otlp.nr-data.net:4317'
: 'https://otlp.nr-data.net:4318/v1/traces'),
otlpHeaders: {
...(licenseKey ? {'api-key': licenseKey} : {}),
...config.otlpHeaders,
},
resourceAttributes: {
'vendor.apm': 'newrelic',
...config.resourceAttributes,
},
};
}
}With the double call, the config.otlpHeaders spread runs twice, which can put headers in a state that's tricky to trace back to the source.
There was a problem hiding this comment.
Fixed. profile defaults are now applied once during bootstrap resolution; vendor profile initialize() no longer reapplies them.
| return await fn(); | ||
| } catch (error) { | ||
| span.recordException(error as Error); | ||
| throw error; |
There was a problem hiding this comment.
The catch block records the exception but doesn't follow up with span.setStatus({ code: SpanStatusCode.ERROR }). Per the OpenTelemetry spec, recording an exception and setting the span status are two separate operations — the exception is attached as an event on the span, but the span status stays UNSET unless explicitly updated. Most trace backends (Jaeger, Grafana Tempo, Datadog, New Relic) use the span status to colour and surface error traces, so without this, spans that throw would appear successful in dashboards.
Worth noting that recordException in errors.ts already handles this correctly — withSpan just needs to match it:
import {context, trace, SpanStatusCode} from '@opentelemetry/api';
// inside the catch block:
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
span.end();
}This is the one thing I'd consider a hard blocker — without it the package would make errors harder to find, which is the opposite of what observability is for.
There was a problem hiding this comment.
Fixed. withSpan() now marks failed spans with SpanStatusCode.ERROR in addition to recording the exception, so traces no longer appear successful on error paths.
| validateObservabilityConfig(profileConfig); | ||
|
|
||
| const initResult = profile.initialize(profileConfig, { | ||
| createExporter: createOtlpExporter, |
There was a problem hiding this comment.
Two related reliability gaps I wanted to flag together here.
First — if profile.initialize() throws (an instrumentation ABI mismatch, a tracerProvider.register() failure, a missing peer dep slipping past validation), updateRuntimeState({profile, config}) has already run but updateRuntimeState({runtime}) hasn't. A subsequent call to bootstrapObservability() then sees runtime === undefined, re-enters against corrupted partial state, and the original error is never logged. This can be surprisingly hard to diagnose in production.
Second — await runtime.shutdown() in shutdownObservability() isn't wrapped in a try-catch. The OTel SDK's tracerProvider.shutdown() can reject on flush timeout, which becomes an unhandled rejection in a SIGTERM handler and crashes the process in Node 15+.
// bootstrapObservability — wrap initialization in try-catch
try {
const profile = resolveProfile(resolvedConfig.profile);
const profileConfig = profile.applyDefaults(resolvedConfig);
validateObservabilityConfig(profileConfig);
const initResult = profile.initialize(profileConfig, {
createExporter: createOtlpExporter,
});
const runtime: ObservabilityRuntime = {
enabled: true,
profile: profile.name,
config: profileConfig,
tracerProvider: initResult.tracerProvider,
async shutdown() {
await profile.shutdown?.();
clearRuntimeState();
},
};
updateRuntimeState({profile, config: profileConfig, runtime});
return runtime;
} catch (err) {
clearRuntimeState(); // prevent corrupted partial state on retry
console.error('[observability] Initialization failed. Tracing will be disabled.', err);
throw err;
}
// shutdownObservability — guard against rejection
export async function shutdownObservability(): Promise<void> {
const runtime = getRuntimeState().runtime;
if (!runtime) return;
try {
await runtime.shutdown();
} catch (err) {
// Log but don't rethrow — a shutdown error must not crash
// the process during SIGTERM handling.
console.error(
'[observability] Error during shutdown. Some spans may not have been flushed.',
err,
);
}
}The clearRuntimeState() on failure is the key part — it ensures a retry starts clean rather than against partial state.
There was a problem hiding this comment.
Fixed. bootstrap now guards profile initialization and clears partial runtime state on failure. shutdownObservability() also handles shutdown errors defensively so process termination does not leave the singleton in a corrupt state.
There was a problem hiding this comment.
Really impressive work here — the 3-tier config pipeline, the BaseOtlpObservabilityProfile abstraction shared cleanly across all four vendor profiles, and the lazy require() approach for optional instrumentation are all the right design choices for a package like this. The ProfileBootstrapContext injection seam is a particularly nice touch that makes testing straightforward without pulling in real OTel packages.
A few things I've flagged inline that I'd like to see addressed before this merges:
Worth resolving before merge:
- Double
applyDefaultsinNewRelicObservabilityProfile,DatadogObservabilityProfile, andSignozObservabilityProfile—bootstrap.tsalready callsapplyDefaultsbeforeinitialize, so the overrides run it twice SpanStatusCode.ERRORmissing inwithSpancatch block — without this, error spans appear successful in every trace backend (Jaeger, Datadog, Grafana Tempo, etc.)- No error handling around
profile.initialize()inbootstrapObservability()— a failure there leaves the module singleton in a corrupted partial state with no logging; andshutdownObservability()needs a try-catch guard sincetracerProvider.shutdown()can reject in a SIGTERM handler
Happy to defer to a follow-up:
ObservabilityProfileRegistryis wired into the component butbootstrapObservability()never consults it — a custom profile registered via LoopBack DI is silently ignored today. Worth either connecting it or leaving a comment noting it's planned for a later iteration- Exporter peer deps (
@opentelemetry/exporter-trace-otlp-http,-grpc) don't have the same pre-flight check that instrumentation packages get invalidateObservabilityConfig— a missing exporter gives a rawMODULE_NOT_FOUNDinstead of a helpful install prompt - Test coverage for the idempotency guard,
shutdownObservability(), and thewithSpanerror rethrow path
Also worth confirming — are the prettier failures and Node 24 EBADENGINE warnings in CI pre-existing on the base branch, or introduced here?
| @inject(ObservabilityBindings.config, {optional: true}) | ||
| private readonly config?: ObservabilityConfig, | ||
| ) { | ||
| const runtime = getRuntimeState().runtime ?? bootstrapObservability(); |
There was a problem hiding this comment.
🟠 Architecture: No LoopBack Sequence integration for tracer/meter enrichment
Issue: The component bootstraps OTEL but never wires tracer or meter into the LoopBack request sequence. Without a custom Sequence (or SequenceActions), there is no automatic span-per-request, no correlation of traceId/spanId into request logs, and no request-level metrics (counters, histograms). The tracerProvider sits in the DI container but nothing starts a root span for incoming HTTP requests.
Fix: Add an ObservabilitySequence (or ObservabilityAction) that wraps each request in a span and injects trace context into the logger:
// packages/observability/src/sequence.ts
import {inject} from '@loopback/core';
import {RestBindings, SequenceHandler, RequestContext} from '@loopback/rest';
import {trace, context, propagation} from '@opentelemetry/api';
import {ObservabilityBindings} from './keys';
export class ObservabilitySequence implements SequenceHandler {
constructor(
@inject(RestBindings.Http.CONTEXT)
private readonly requestCtx: RequestContext,
@inject(ObservabilityBindings.runtime, {optional: true})
private readonly runtime?: ObservabilityRuntime,
) {}
async handle(request: Request, response: Response): Promise<void> {
const tracer = trace.getTracer('@sourceloop/observability');
const carrier: Record<string, string> = {};
// Extract incoming W3C traceparent from request headers
propagation.extract(context.active(), request.headers, {
get: (h, k) => h[k] as string,
keys: h => Object.keys(h),
});
// Start root span per request
await tracer.startActiveSpan(`${request.method} ${request.path}`, async span => {
try {
// inject traceId into logger context here
await next(); // delegate to LoopBack routing
} catch (err) {
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
}
}Then bind it in ObservabilityComponent:
this.application.sequence(ObservabilitySequence);This is the standard LoopBack 4 pattern for cross-cutting request concerns (auth, logging, tracing).
Credit: Open Code
| resourceAttributes: Record<string, string>; | ||
| } | ||
|
|
||
| export interface ProfileInitResult { |
There was a problem hiding this comment.
🟠 Missing: Metrics (MeterProvider) support — traces-only design
Issue: ProfileInitResult and ObservabilityRuntime expose only tracerProvider. There is no meterProvider in the type definitions, no @opentelemetry/sdk-metrics integration, and no way for consumers to record counters, histograms, or gauges. The PR title says "OTEL-first" but ships traces only — metrics and logs are absent from the type contract.
Fix: Extend the types to include metrics:
import {MeterProvider} from '@opentelemetry/sdk-metrics';
export interface ProfileInitResult {
exporterName: string;
tracerProvider: NodeTracerProvider;
meterProvider?: MeterProvider; // add
}
export interface ObservabilityRuntime {
enabled: boolean;
profile: ObservabilityProfileName;
config: ResolvedObservabilityConfig;
tracerProvider?: NodeTracerProvider;
meterProvider?: MeterProvider; // add
shutdown(): Promise<void>;
}And wire MeterProvider in BaseOtlpObservabilityProfile.initialize() alongside NodeTracerProvider.
Credit: Open Code
| runtime?: ObservabilityRuntime; | ||
| }; | ||
|
|
||
| const state: RuntimeState = {}; |
There was a problem hiding this comment.
🔴 Concurrency: Module-level mutable singleton breaks test isolation and multi-instance apps
Issue: const state: RuntimeState = {} is a module-level singleton mutated in place. In test suites that don't call clearRuntimeState() between tests (or where tests run in parallel), state bleeds across tests. In monorepo scenarios where multiple services are loaded in the same process (e.g. via require), they share this singleton — whichever bootstraps first wins and silently ignores subsequent configs.
Fix: Either:
- Use a
Symbol-keyed property onglobalThisso it is truly process-global and explicit, or - Accept a
storeparameter ingetRuntimeState/updateRuntimeStatefor testability:
// Explicit global registry (option 1)
const STATE_KEY = Symbol.for('@sourceloop/observability.state');
function getGlobalState(): RuntimeState {
if (!(globalThis as Record<symbol, unknown>)[STATE_KEY]) {
(globalThis as Record<symbol, unknown>)[STATE_KEY] = {};
}
return (globalThis as Record<symbol, unknown>)[STATE_KEY] as RuntimeState;
}Also: clearRuntimeState uses property-by-property = undefined which is fragile — add a new key to RuntimeState and you must remember to clear it here too. Prefer Object.assign(state, {bootstrapOverrides: undefined, config: undefined, profile: undefined, runtime: undefined}) or delete all keys in a loop.
Credit: Open Code
| }; | ||
| } | ||
|
|
||
| export function bootstrapObservability( |
There was a problem hiding this comment.
🟠 No mutex / race guard on concurrent bootstrapObservability() calls
Issue: bootstrapObservability checks getRuntimeState().runtime and returns early if set. But two concurrent callers (e.g., two services starting simultaneously in the same process) can both pass the guard before either sets updateRuntimeState({runtime}) at line 101. This results in double-registration of the tracerProvider and two sets of instrumentations being activated.
Fix: Add a synchronous initialization lock:
let bootstrapping = false;
export function bootstrapObservability(
overrides?: Partial<ObservabilityConfig>,
): ObservabilityRuntime {
const existingRuntime = getRuntimeState().runtime;
if (existingRuntime) return existingRuntime;
if (bootstrapping) {
throw new Error('bootstrapObservability called re-entrantly; ensure it is called once at startup.');
}
bootstrapping = true;
try {
// ... existing logic
} finally {
bootstrapping = false;
}
}Credit: Open Code
|
|
||
| function loadOtlpExporter(config: ResolvedObservabilityConfig): SpanExporter { | ||
| if (config.exporterProtocol === 'grpc') { | ||
| const otlpGrpc = require('@opentelemetry/exporter-trace-otlp-grpc') as { |
There was a problem hiding this comment.
🟠 require() inside methods bypasses module bundlers and breaks type safety
Issue: loadOtlpExporter uses require('@opentelemetry/exporter-trace-otlp-grpc') and require('@opentelemetry/exporter-trace-otlp-http') inside the function body. This pattern:
- Prevents tree-shaking and static analysis
- Gives
any-typed return that is immediately cast — if the module shape changes, this fails silently at runtime - Errors thrown during
require()are uncaught here and will crash the process with an unhelpful stack trace
Fix: Wrap in try/catch with a descriptive error, or use dynamic import() (which is async and works with ESM):
function loadOtlpExporter(config: ResolvedObservabilityConfig): SpanExporter {
try {
if (config.exporterProtocol === 'grpc') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {OTLPTraceExporter} = require('@opentelemetry/exporter-trace-otlp-grpc');
return new OTLPTraceExporter({url: config.otlpEndpoint, metadata: ...});
}
const {OTLPTraceExporter} = require('@opentelemetry/exporter-trace-otlp-http');
return new OTLPTraceExporter({url: config.otlpEndpoint, headers: ...});
} catch (err) {
throw new Error(
`Failed to load OTLP exporter for protocol "${config.exporterProtocol}". ` +
`Install the required peer dependency. Original error: ${(err as Error).message}`
);
}
}Credit: Open Code
| return carrier; | ||
| } | ||
|
|
||
| export function getTraceContext(): { |
There was a problem hiding this comment.
🟡 getTraceContext always calls createPropagationHeaders() — allocates a new carrier even when no span is active
Issue: getTraceContext() calls createPropagationHeaders() unconditionally, which calls propagation.inject(context.active(), carrier). When no span is active (activeSpan is undefined), this allocates and injects into an empty carrier unnecessarily on every call.
Also, for enriching logs/metrics with trace correlation, consumers typically need traceId and spanId in a structured format, but the returned object mixes OTEL-internal IDs with W3C propagation headers — two different concerns.
Fix: Guard the propagation call:
export function getTraceContext() {
const activeSpan = trace.getSpan(context.active());
const spanContext = activeSpan?.spanContext();
if (!spanContext) {
return {traceId: undefined, spanId: undefined, traceparent: undefined, tracestate: undefined};
}
const carrier = createPropagationHeaders();
return {
traceId: spanContext.traceId,
spanId: spanContext.spanId,
traceparent: carrier.traceparent,
tracestate: carrier.tracestate,
};
}Credit: Open Code
| INSTRUMENTATION_MODULE_REQUIREMENTS, | ||
| } from '../profiles/instrumentations'; | ||
|
|
||
| function assertDependencyInstalled(moduleName: string, message: string): void { |
There was a problem hiding this comment.
🟡 isDependencyInstalled duplicates isModuleInstalled in instrumentations.ts
Issue: isDependencyInstalled (line 17) and assertDependencyInstalled (line 9) both use require.resolve inside try/catch — the exact same pattern as isModuleInstalled in profiles/instrumentations.ts:45. This is code duplication across two files.
Fix: Extract a shared utility into packages/observability/src/utils/module-resolution.ts:
export function isModuleInstalled(moduleName: string): boolean {
try { require.resolve(moduleName); return true; } catch { return false; }
}
export function assertModuleInstalled(moduleName: string, message: string): void {
if (!isModuleInstalled(moduleName)) throw new Error(message);
}Import from both validate-config.ts and instrumentations.ts.
Credit: Open Code
| this.tracerProvider = tracerProvider; | ||
|
|
||
| return { | ||
| exporterName: loadOtlpExporter.name, |
There was a problem hiding this comment.
🟡 exporterName returns function name of internal helper — leaks implementation detail
Issue: ProfileInitResult.exporterName is set to loadOtlpExporter.name (line 117), which evaluates to the string 'loadOtlpExporter'. This exposes an internal private function name in the public API contract. The integration test at runtime.integration.ts:85 asserts strictEqual(initResult.exporterName, 'loadOtlpExporter') — this will silently break if the function is renamed or minified.
Fix: Use a stable semantic string:
return {
exporterName: `otlp-${config.exporterProtocol}`, // e.g. 'otlp-grpc' or 'otlp-http/protobuf'
tracerProvider,
};Update the integration test assertion accordingly.
Credit: Open Code
| @inject(ObservabilityBindings.config, {optional: true}) | ||
| private readonly config?: ObservabilityConfig, | ||
| ) { | ||
| const runtime = getRuntimeState().runtime ?? bootstrapObservability(); |
There was a problem hiding this comment.
🟠 bootstrapObservability() called in component constructor — side effects at DI bind time
Issue: Line 40 calls getRuntimeState().runtime ?? bootstrapObservability(). bootstrapObservability() registers the global NodeTracerProvider (via tracerProvider.register() in otlp.profile.ts:112), which patches @opentelemetry/api globals. This happens during LoopBack DI binding resolution, before the app is fully started. If the app fails to start after this point, the global tracer provider is registered but never shut down, leaking instrumentation hooks.
Fix: Move bootstrapObservability() to a LoopBack lifecycle hook (start/stop) to align with the application lifecycle:
export class ObservabilityComponent implements Component, LifeCycleObserver {
async start(): Promise<void> {
const runtime = bootstrapObservability(this.config);
this.application.bind(ObservabilityBindings.runtime).to(runtime);
}
async stop(): Promise<void> {
await shutdownObservability();
}
}Credit: Open Code
| } | ||
| } | ||
|
|
||
| function loadInstrumentations( |
There was a problem hiding this comment.
🟠 Maintainability: Use @opentelemetry/auto-instrumentations-node instead of individual instrumentation packages
Issue: The current implementation manually require()s 6+ individual OTEL instrumentation packages and maintains a custom loadInstrumentations switch-case registry. Problems:
- Maintenance burden: Every new library requires a new
case, a newINSTRUMENTATION_MODULE_REQUIREMENTSentry, and a newInstrumentationToggleskey - Version drift: 6+ packages each have independent release cycles — keeping them in sync with the SDK is error-prone
- Incomplete coverage: Misses many libraries the OTEL ecosystem already supports (MongoDB, TypeORM, gRPC, DNS, Net, etc.)
Fix: Replace the entireloadInstrumentationsswitch with@opentelemetry/auto-instrumentations-node, which bundles all supported instrumentations and accepts a per-package{enabled: boolean}config:
package.json — one dep instead of 6+:
{
"peerDependencies": {
"@opentelemetry/auto-instrumentations-node": "^0.56.0"
}
}import {getNodeAutoInstrumentations} from '@opentelemetry/auto-instrumentations-node';
import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node';
import {InstrumentationToggles, ResolvedObservabilityConfig} from '../types';
// Maps our toggle keys to the package names auto-instrumentations-node expects
const TOGGLE_TO_PACKAGE: Record<keyof InstrumentationToggles, string[]> = {
http: ['@opentelemetry/instrumentation-http'],
express: ['@opentelemetry/instrumentation-express'],
pg: ['@opentelemetry/instrumentation-pg'],
mysql: ['@opentelemetry/instrumentation-mysql', '@opentelemetry/instrumentation-mysql2'],
redis: ['@opentelemetry/instrumentation-redis-4'],
kafka: ['@opentelemetry/instrumentation-kafkajs'],
};
export function createAutoInstrumentations(
config: ResolvedObservabilityConfig,
tracerProvider: NodeTracerProvider,
) {
const enabledPackages = new Set<string>();
for (const [toggle, packages] of Object.entries(TOGGLE_TO_PACKAGE)) {
if (config.instrumentations[toggle as keyof InstrumentationToggles]) {
packages.forEach(p => enabledPackages.add(p));
}
}
// All packages disabled by default; only toggled-on ones are active
const allPkgs = Object.values(TOGGLE_TO_PACKAGE).flat();
const instrumentations = getNodeAutoInstrumentations(
Object.fromEntries(allPkgs.map(pkg => [pkg, {enabled: enabledPackages.has(pkg)}]))
);
const all = [...instrumentations, ...config.customInstrumentations];
for (const inst of all) {
inst.setTracerProvider?.(tracerProvider);
inst.enable();
}
return all;
}Benefits:
- Single peer dep to maintain and version-bump
- Automatically gains new instrumentations as the package adds them
- enabled: false entries are zero-cost no-ops at runtime
- Backwards-compatible with existing InstrumentationToggles contract — no breaking change for consumers
- Eliminates INSTRUMENTATION_MODULE_REQUIREMENTS and the peer dep checks in validate-config.ts entirely — auto-instrumentations-node handles graceful skipping internally
Credit: Open Code
…observability and tracing gh-0
…plexity from #2502 (#2503) * fix: Commit 1 - Fully fix typescript:S1541 Commit 1 of SonarQube suggestions Fully fixed issues: - [typescript:S1541] AZ2I0JCED4ljEi2GS_-X: Function has a complexity of 13 which is greater than 10 authorized. - [typescript:S1541] AZ2I0JCED4ljEi2GS_-Y: Function has a complexity of 21 which is greater than 10 authorized. Generated by SonarQube Agent * fix: Commit 2 - Fully fix typescript:S1541, typescript:S134 Commit 2 of SonarQube suggestions Fully fixed issues: - [typescript:S1541] AZ2I0JE5D4ljEi2GS_-c: Function has a complexity of 14 which is greater than 10 authorized. - [typescript:S134] AZ2I0JE5D4ljEi2GS_-d: Refactor this code to not nest more than 3 if/for/while/switch/try statements. Generated by SonarQube Agent --------- Co-authored-by: sonarqube-agent[bot] <210722872+sonarqube-agent[bot]@users.noreply.github.com>
SonarQube reviewer guideSummary: Introduces a new Review Focus:
Start review at:
|



Description
This PR introduces a new reusable package, @sourceloop/observability, to provide a generic OpenTelemetry-first observability layer for Sourceloop services.
The package is designed around:
Configuration model
Startup-critical observability config is resolved during bootstrap from:
LoopBack DI config is still supported, but only for component-level enrichment rather than early startup control.
Instrumentation behavior
By default, only:
are enabled.
All other built-in instrumentations are disabled by default and must be explicitly enabled through bootstrap config or env vars.
Instrumentation packages are optional peer dependencies. If an instrumentation is enabled but its package is not installed, bootstrap fails fast with a clear validation error.
Type of change
Please delete options that are not relevant.
How Has This Been Tested?
Verified locally with:
Checklist: