Skip to content

Commit 71f8023

Browse files
os-zhuangCopilot
andcommitted
feat(observability): ObservabilityServicePlugin + wire service plugins (PR #3)
Resolves the gap left by PR #2: cache + storage adapters accepted an optional MetricsRegistry but their respective Plugin classes never forwarded one, so any host that registered observability via the dispatcher saw zero cache/storage data. Adds: packages/observability/src/service-names.ts OBSERVABILITY_METRICS_SERVICE = 'observability:metrics' OBSERVABILITY_ERRORS_SERVICE = 'observability:errors' packages/runtime/src/observability/observability-service-plugin.ts ObservabilityServicePlugin — registers the host's MetricsRegistry and ErrorReporter under the canonical names. Defaults each to its respective Noop exporter so the services are always present. Also exports resolveMetrics() / resolveErrorReporter() helpers for consumers inside the runtime package. CacheServicePlugin + StorageServicePlugin: - new `metrics?: MetricsRegistry` option (escape hatch for tests) - canonical resolution chain at init(): option override → observability:metrics service → NoopMetricsRegistry - StorageServicePlugin's `buildAdapterFromValues` (the settings live-rebuild path) now also threads metrics into the freshly built adapter, so adapter swaps don't drop instrumentation - log line now reports the resolved registry class name for diagnostics Helpers (resolveMetrics) are inlined as private functions in each service to avoid a circular dep (services must not depend on runtime). Constants live in @objectstack/observability which both services already depend on. New tests: - cache-service-plugin.metrics.test.ts (4 tests, resolution chain + override precedence) - storage-service-plugin.metrics.test.ts (4 tests, incl. settings-rebuild path) - observability-service-plugin.test.ts (3 tests, registration + defaults) All 22 service-cache + 48 service-storage + 282 runtime tests pass (2 pre-existing i18n failures in app-plugin.test.ts on main unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3801b0a commit 71f8023

10 files changed

Lines changed: 519 additions & 5 deletions

File tree

packages/observability/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
// Contracts
1111
export type { MetricsRegistry, MetricSample, ErrorReporter, CapturedError, Logger } from './contracts.js';
1212

13+
// Service-registry names (consumed by runtime's ObservabilityServicePlugin and lookup sites)
14+
export { OBSERVABILITY_METRICS_SERVICE, OBSERVABILITY_ERRORS_SERVICE } from './service-names.js';
15+
1316
// Semantic conventions
1417
export { SEMCONV, RUNTIME_METRICS } from './semconv.js';
1518

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Canonical service-registry names for the host's observability
5+
* backends. Plugins look these up to discover the configured
6+
* {@link MetricsRegistry} / {@link ErrorReporter} without each host
7+
* having to thread observability config through every plugin
8+
* constructor.
9+
*
10+
* See `@objectstack/runtime` → `ObservabilityServicePlugin` for the
11+
* registration side.
12+
*/
13+
export const OBSERVABILITY_METRICS_SERVICE = 'observability:metrics';
14+
export const OBSERVABILITY_ERRORS_SERVICE = 'observability:errors';

packages/runtime/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export {
6868
InMemoryErrorReporter,
6969
type ErrorReporter,
7070
type CapturedError,
71+
ObservabilityServicePlugin,
72+
OBSERVABILITY_METRICS_SERVICE,
73+
OBSERVABILITY_ERRORS_SERVICE,
74+
resolveMetrics,
75+
resolveErrorReporter,
76+
type ObservabilityServicePluginOptions,
7177
} from './observability/index.js';
7278

7379
// Export Artifact Loader

packages/runtime/src/observability/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,12 @@ export {
2828
instrumentRouteHandler,
2929
type InstrumentOptions,
3030
} from './instrument.js';
31+
32+
export {
33+
ObservabilityServicePlugin,
34+
OBSERVABILITY_METRICS_SERVICE,
35+
OBSERVABILITY_ERRORS_SERVICE,
36+
resolveMetrics,
37+
resolveErrorReporter,
38+
type ObservabilityServicePluginOptions,
39+
} from './observability-service-plugin.js';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
InMemoryMetricsRegistry,
6+
InMemoryErrorReporter,
7+
NoopMetricsRegistry,
8+
NoopErrorReporter,
9+
OBSERVABILITY_METRICS_SERVICE,
10+
OBSERVABILITY_ERRORS_SERVICE,
11+
} from '@objectstack/observability';
12+
import { ObservabilityServicePlugin } from './observability-service-plugin';
13+
14+
/**
15+
* The plugin's only job is to register the host's metrics + error
16+
* backends under canonical service names so other plugins can resolve
17+
* them without each host threading config through every constructor.
18+
*/
19+
20+
function makeCtx() {
21+
const services = new Map<string, any>();
22+
return {
23+
logger: { info: () => {}, warn: () => {}, error: () => {} },
24+
registerService: (name: string, svc: any) => { services.set(name, svc); },
25+
getService: <T>(name: string): T => {
26+
const s = services.get(name);
27+
if (!s) throw new Error(`service '${name}' not registered`);
28+
return s as T;
29+
},
30+
_services: services,
31+
} as any;
32+
}
33+
34+
describe('ObservabilityServicePlugin', () => {
35+
it('registers explicit metrics + error backends under canonical names', async () => {
36+
const metrics = new InMemoryMetricsRegistry();
37+
const errors = new InMemoryErrorReporter();
38+
const ctx = makeCtx();
39+
40+
await new ObservabilityServicePlugin({ metrics, errors }).init(ctx);
41+
42+
expect(ctx._services.get(OBSERVABILITY_METRICS_SERVICE)).toBe(metrics);
43+
expect(ctx._services.get(OBSERVABILITY_ERRORS_SERVICE)).toBe(errors);
44+
});
45+
46+
it('defaults to noop backends when no options provided', async () => {
47+
const ctx = makeCtx();
48+
await new ObservabilityServicePlugin().init(ctx);
49+
50+
expect(ctx._services.get(OBSERVABILITY_METRICS_SERVICE)).toBeInstanceOf(NoopMetricsRegistry);
51+
expect(ctx._services.get(OBSERVABILITY_ERRORS_SERVICE)).toBeInstanceOf(NoopErrorReporter);
52+
});
53+
54+
it('lets one backend be set while the other defaults to noop', async () => {
55+
const metrics = new InMemoryMetricsRegistry();
56+
const ctx = makeCtx();
57+
await new ObservabilityServicePlugin({ metrics }).init(ctx);
58+
59+
expect(ctx._services.get(OBSERVABILITY_METRICS_SERVICE)).toBe(metrics);
60+
expect(ctx._services.get(OBSERVABILITY_ERRORS_SERVICE)).toBeInstanceOf(NoopErrorReporter);
61+
});
62+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Plugin, PluginContext } from '@objectstack/core';
4+
import {
5+
OBSERVABILITY_METRICS_SERVICE,
6+
OBSERVABILITY_ERRORS_SERVICE,
7+
} from '@objectstack/observability';
8+
import {
9+
NoopMetricsRegistry,
10+
NoopErrorReporter,
11+
type MetricsRegistry,
12+
type ErrorReporter,
13+
} from './index.js';
14+
15+
/**
16+
* Canonical service names other plugins look up to find the host's
17+
* configured observability backends. Re-exported from
18+
* `@objectstack/observability` so callers can grab them alongside the
19+
* plugin without straddling two packages.
20+
*/
21+
export { OBSERVABILITY_METRICS_SERVICE, OBSERVABILITY_ERRORS_SERVICE };
22+
23+
/**
24+
* Options for {@link ObservabilityServicePlugin}.
25+
*
26+
* Either or both backends can be omitted; the omitted one resolves to
27+
* the corresponding no-op exporter so consumers can always rely on the
28+
* service being present.
29+
*/
30+
export interface ObservabilityServicePluginOptions {
31+
/** Metrics backend (e.g. `ConsoleMetricsRegistry`, `OtlpHttpMetricsRegistry`). */
32+
metrics?: MetricsRegistry;
33+
/** Error reporter backend (e.g. Sentry adapter). */
34+
errors?: ErrorReporter;
35+
}
36+
37+
/**
38+
* `ObservabilityServicePlugin` — registers the host's
39+
* {@link MetricsRegistry} and {@link ErrorReporter} in the kernel
40+
* service registry under the canonical names so any other plugin can
41+
* look them up without each host having to thread observability config
42+
* through every plugin constructor.
43+
*
44+
* Resolution chain other plugins should follow:
45+
*
46+
* 1. Explicit option on the plugin's own options object (escape
47+
* hatch for tests / explicit wiring).
48+
* 2. `ctx.getService(OBSERVABILITY_METRICS_SERVICE)`.
49+
* 3. `NoopMetricsRegistry()` — never null.
50+
*
51+
* Register this plugin **before** any plugin that wants to consume the
52+
* services (cache, storage, dispatcher), otherwise the consumer's
53+
* `init` will fall through to the no-op default.
54+
*
55+
* @example
56+
* ```ts
57+
* import { ObjectKernel } from '@objectstack/core';
58+
* import {
59+
* ObservabilityServicePlugin,
60+
* CacheServicePlugin,
61+
* } from '@objectstack/runtime';
62+
* import { ConsoleMetricsRegistry } from '@objectstack/observability';
63+
*
64+
* const kernel = new ObjectKernel();
65+
* kernel.use(new ObservabilityServicePlugin({
66+
* metrics: new ConsoleMetricsRegistry(),
67+
* }));
68+
* kernel.use(new CacheServicePlugin()); // picks metrics up automatically
69+
* ```
70+
*/
71+
export class ObservabilityServicePlugin implements Plugin {
72+
name = 'com.objectstack.observability.service';
73+
version = '1.0.0';
74+
type = 'standard';
75+
76+
private readonly options: ObservabilityServicePluginOptions;
77+
78+
constructor(options: ObservabilityServicePluginOptions = {}) {
79+
this.options = options;
80+
}
81+
82+
async init(ctx: PluginContext): Promise<void> {
83+
const metrics = this.options.metrics ?? new NoopMetricsRegistry();
84+
const errors = this.options.errors ?? new NoopErrorReporter();
85+
ctx.registerService(OBSERVABILITY_METRICS_SERVICE, metrics);
86+
ctx.registerService(OBSERVABILITY_ERRORS_SERVICE, errors);
87+
ctx.logger.info(
88+
`ObservabilityServicePlugin: registered metrics=${(metrics as any).constructor?.name ?? 'unknown'} errors=${(errors as any).constructor?.name ?? 'unknown'}`,
89+
);
90+
}
91+
}
92+
93+
/**
94+
* Helper used by consumer plugins to resolve a metrics backend with
95+
* the canonical fallback chain. Never throws; always returns a usable
96+
* `MetricsRegistry`.
97+
*
98+
* @param ctx the consuming plugin's `PluginContext`
99+
* @param override explicit option set on the consuming plugin (wins)
100+
*/
101+
export function resolveMetrics(
102+
ctx: PluginContext,
103+
override?: MetricsRegistry,
104+
): MetricsRegistry {
105+
if (override) return override;
106+
try {
107+
const m = ctx.getService<MetricsRegistry | undefined>(OBSERVABILITY_METRICS_SERVICE);
108+
if (m) return m;
109+
} catch {
110+
// Service not registered — fall through to the noop default.
111+
}
112+
return new NoopMetricsRegistry();
113+
}
114+
115+
/**
116+
* Sibling of {@link resolveMetrics} for {@link ErrorReporter}.
117+
*/
118+
export function resolveErrorReporter(
119+
ctx: PluginContext,
120+
override?: ErrorReporter,
121+
): ErrorReporter {
122+
if (override) return override;
123+
try {
124+
const e = ctx.getService<ErrorReporter | undefined>(OBSERVABILITY_ERRORS_SERVICE);
125+
if (e) return e;
126+
} catch {
127+
// Service not registered — fall through to the noop default.
128+
}
129+
return new NoopErrorReporter();
130+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
InMemoryMetricsRegistry,
6+
OBSERVABILITY_METRICS_SERVICE,
7+
SEMCONV,
8+
} from '@objectstack/observability';
9+
import { CacheServicePlugin } from './cache-service-plugin';
10+
11+
/**
12+
* Verifies the metrics resolution chain wired in PR #3:
13+
* option override → service-registry lookup → noop fallback
14+
*
15+
* Without these tests it's easy to silently regress to the pre-PR-3
16+
* behaviour where the plugin built an unmetered adapter and OTLP saw
17+
* zero cache traffic.
18+
*/
19+
20+
function makeCtx() {
21+
const services = new Map<string, any>();
22+
return {
23+
logger: { info: () => {}, warn: () => {}, error: () => {} },
24+
registerService: (name: string, svc: any) => { services.set(name, svc); },
25+
getService: <T>(name: string): T => {
26+
const s = services.get(name);
27+
if (!s) throw new Error(`service '${name}' not registered`);
28+
return s as T;
29+
},
30+
_services: services,
31+
} as any;
32+
}
33+
34+
describe('CacheServicePlugin observability wiring', () => {
35+
it('uses an explicit metrics option when provided', async () => {
36+
const metrics = new InMemoryMetricsRegistry();
37+
const ctx = makeCtx();
38+
await new CacheServicePlugin({ metrics }).init(ctx);
39+
40+
const cache = ctx._services.get('cache');
41+
await cache.set('k', 'v', 60);
42+
await cache.get('k');
43+
44+
expect(metrics.samples.length).toBeGreaterThan(0);
45+
expect(metrics.samples.some((s) => s.name === SEMCONV.cacheLookupsTotal)).toBe(true);
46+
});
47+
48+
it('falls back to the metrics service registered under observability:metrics', async () => {
49+
const metrics = new InMemoryMetricsRegistry();
50+
const ctx = makeCtx();
51+
ctx._services.set(OBSERVABILITY_METRICS_SERVICE, metrics);
52+
53+
await new CacheServicePlugin().init(ctx);
54+
const cache = ctx._services.get('cache');
55+
await cache.set('k', 'v', 60);
56+
await cache.get('k');
57+
58+
expect(metrics.samples.some((s) => s.name === SEMCONV.cacheLookupsTotal)).toBe(true);
59+
});
60+
61+
it('uses a noop registry when neither option nor service is present', async () => {
62+
const ctx = makeCtx();
63+
await new CacheServicePlugin().init(ctx);
64+
const cache = ctx._services.get('cache');
65+
// Should not throw — proves the adapter got *something*.
66+
await cache.set('k', 'v', 60);
67+
expect(await cache.get('k')).toBe('v');
68+
});
69+
70+
it('explicit option wins over a registered service', async () => {
71+
const fromOption = new InMemoryMetricsRegistry();
72+
const fromService = new InMemoryMetricsRegistry();
73+
const ctx = makeCtx();
74+
ctx._services.set(OBSERVABILITY_METRICS_SERVICE, fromService);
75+
76+
await new CacheServicePlugin({ metrics: fromOption }).init(ctx);
77+
const cache = ctx._services.get('cache');
78+
await cache.set('k', 'v', 60);
79+
80+
expect(fromOption.samples.length).toBeGreaterThan(0);
81+
expect(fromService.samples.length).toBe(0);
82+
});
83+
});

0 commit comments

Comments
 (0)