@mfeflow/telemetry 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanjeev Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # @mfeflow/telemetry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Telemetry for micro-frontends behind one provider interface. The shell starts it once; remotes take a tracer. Ships an OpenTelemetry web provider (OTLP/HTTP export, `fetch` tracing, Core Web Vitals) meant to load after first render.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @mfeflow/telemetry
9
+ ```
10
+
11
+ In a project made with mfeflow, `mfeflow add telemetry-otel` does all of this.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ // shell, once: the SDK is a separate chunk, loaded after the first paint
17
+ import { initTelemetry } from '@mfeflow/telemetry';
18
+
19
+ initTelemetry(async () => {
20
+ const { createOtelProvider } = await import('@mfeflow/telemetry/otel');
21
+ return createOtelProvider({
22
+ serviceName: 'shell',
23
+ version: '1.0.0',
24
+ environment: 'prod',
25
+ otlpEndpoint: 'https://collector.example.com/v1/traces',
26
+ });
27
+ });
28
+ ```
29
+
30
+ ```ts
31
+ // a remote
32
+ import { getTracer } from '@mfeflow/telemetry';
33
+
34
+ const tracer = getTracer('cart'); // every span carries mfe.name = cart
35
+ tracer.startActiveSpan('cart.click', (span) => {
36
+ void fetch('/api/orders').finally(() => span.end()); // start the fetch before any await
37
+ });
38
+ ```
39
+
40
+ Remote failures reported by `@mfeflow/runtime` are forwarded through the `window` event it fires; nothing to wire.
41
+
42
+ ## Module Federation sharing
43
+
44
+ Bucket: **singleton**. Share `@mfeflow/telemetry` (`@mfeflow/rsbuild` does). `@mfeflow/telemetry/otel` is imported by the shell only, through a dynamic `import()`. Its exporter and instrumentation packages are 0.x (experimental) OpenTelemetry releases.
45
+
46
+ ## Links
47
+
48
+ - [mfeflow repository](https://github.com/mfeflow/mfeflow) and its README
49
+ - [Documentation](https://mfeflow.github.io/mfeflow/)
50
+
51
+ MIT licensed.
package/dist/core.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { ErrorContext, TelemetryProvider, Tracer, WebVitalMetric } from './types.js';
2
+ export type ProviderSource = TelemetryProvider | (() => Promise<TelemetryProvider>);
3
+ export interface InitOptions {
4
+ /**
5
+ * When to load a provider that comes from a function. `idle` (default) waits for the page to
6
+ * finish loading, the first paint and an idle browser, so the SDK never competes with first render.
7
+ * `now` loads it immediately.
8
+ */
9
+ when?: 'idle' | 'now';
10
+ /** Called when the provider fails to load or start. The noop provider stays in place. */
11
+ onError?: (error: unknown) => void;
12
+ }
13
+ /**
14
+ * Starts telemetry. Only the shell calls this, and only once: later calls return false.
15
+ * A provider given as a function is loaded after first render; `reportError` and
16
+ * `recordWebVitals` calls made until it is ready are kept (the last 50) and replayed, including the
17
+ * remote failures `@mfeflow/runtime` reported before this ran. Spans started before that are not recorded.
18
+ */
19
+ export declare function initTelemetry(source: ProviderSource, options?: InitOptions): boolean;
20
+ /** Resolves when the provider is ready, or has failed to load. */
21
+ export declare function telemetryReady(): Promise<void>;
22
+ export declare function isTelemetryReady(): boolean;
23
+ /** Sends whatever the provider has buffered. For tests and `pagehide` handlers. */
24
+ export declare function flushTelemetry(): Promise<void>;
25
+ /**
26
+ * The tracer for one remote. Every span it starts carries `mfe.name`. Take it once at import time:
27
+ * it follows the provider, so it records as soon as telemetry is ready.
28
+ */
29
+ export declare function getTracer(mfeName: string, mfeVersion?: string): Tracer;
30
+ export declare function reportError(error: unknown, context: ErrorContext): void;
31
+ export declare function recordWebVitals(metric: WebVitalMetric): void;
32
+ /** Only for tests. */
33
+ export declare function resetTelemetryForTests(): void;
package/dist/core.js ADDED
@@ -0,0 +1,107 @@
1
+ import { connectRemoteErrors, disconnectRemoteErrors } from "./errors.js";
2
+ import { noopProvider } from "./noop.js";
3
+ import { MfeTracer } from "./tracer.js";
4
+ const KEY = Symbol.for('mfeflow.telemetry');
5
+ const QUEUE_LIMIT = 50;
6
+ function isState(value) {
7
+ return 'object' == typeof value && null !== value && 'queue' in value && 'provider' in value;
8
+ }
9
+ function state() {
10
+ const existing = Reflect.get(globalThis, KEY);
11
+ if (isState(existing)) return existing;
12
+ const fresh = {
13
+ provider: noopProvider,
14
+ started: false,
15
+ queue: [],
16
+ ready: Promise.resolve()
17
+ };
18
+ Reflect.set(globalThis, KEY, fresh);
19
+ return fresh;
20
+ }
21
+ const PAINT_WAIT_MS = 3000;
22
+ function afterFirstPaint() {
23
+ return new Promise((resolve)=>{
24
+ let started = false;
25
+ const idle = ()=>{
26
+ if ('function' == typeof requestIdleCallback) requestIdleCallback(()=>{
27
+ resolve();
28
+ }, {
29
+ timeout: 2000
30
+ });
31
+ else setTimeout(resolve, 200);
32
+ };
33
+ const afterPaint = ()=>{
34
+ if (started) return;
35
+ started = true;
36
+ idle();
37
+ };
38
+ const painted = ()=>{
39
+ requestAnimationFrame(()=>{
40
+ setTimeout(afterPaint, 0);
41
+ });
42
+ setTimeout(afterPaint, PAINT_WAIT_MS);
43
+ };
44
+ if ('complete' === document.readyState) painted();
45
+ else addEventListener('load', painted, {
46
+ once: true
47
+ });
48
+ });
49
+ }
50
+ function initTelemetry(source, options = {}) {
51
+ const s = state();
52
+ if (s.started) return false;
53
+ s.started = true;
54
+ connectRemoteErrors(reportError);
55
+ const activate = async ()=>{
56
+ try {
57
+ const provider = 'function' == typeof source ? await source() : source;
58
+ await provider.init();
59
+ s.provider = provider;
60
+ for (const call of s.queue.splice(0))call(provider);
61
+ } catch (error) {
62
+ s.queue.length = 0;
63
+ options.onError?.(error);
64
+ }
65
+ };
66
+ const start = 'function' == typeof source && 'now' !== options.when ? afterFirstPaint().then(activate) : activate();
67
+ s.ready = start;
68
+ return true;
69
+ }
70
+ function telemetryReady() {
71
+ return state().ready;
72
+ }
73
+ function isTelemetryReady() {
74
+ return state().provider !== noopProvider;
75
+ }
76
+ async function flushTelemetry() {
77
+ await state().provider.flush?.();
78
+ }
79
+ function send(call) {
80
+ const s = state();
81
+ if (s.provider !== noopProvider) return void call(s.provider);
82
+ if (s.queue.length >= QUEUE_LIMIT) s.queue.shift();
83
+ s.queue.push(call);
84
+ }
85
+ function getTracer(mfeName, mfeVersion) {
86
+ return new MfeTracer(()=>state().provider.getTracer(mfeName), {
87
+ 'mfe.name': mfeName,
88
+ ...void 0 === mfeVersion ? {} : {
89
+ 'mfe.version': mfeVersion
90
+ }
91
+ });
92
+ }
93
+ function reportError(error, context) {
94
+ send((p)=>{
95
+ p.reportError(error, context);
96
+ });
97
+ }
98
+ function recordWebVitals(metric) {
99
+ send((p)=>{
100
+ p.recordWebVitals(metric);
101
+ });
102
+ }
103
+ function resetTelemetryForTests() {
104
+ disconnectRemoteErrors();
105
+ Reflect.deleteProperty(globalThis, KEY);
106
+ }
107
+ export { flushTelemetry, getTracer, initTelemetry, isTelemetryReady, recordWebVitals, reportError, resetTelemetryForTests, telemetryReady };
@@ -0,0 +1,9 @@
1
+ import type { ErrorContext } from './types.js';
2
+ /**
3
+ * Sends every remote failure to `report`: the ones already recorded in `globalThis.mfeflowReports`
4
+ * and each one that follows. Connecting twice (StrictMode, a second copy) does not report twice.
5
+ * Returns a function that disconnects.
6
+ */
7
+ export declare function connectRemoteErrors(report: (error: unknown, context: ErrorContext) => void): () => void;
8
+ /** Disconnects whatever `connectRemoteErrors` connected on this page. */
9
+ export declare function disconnectRemoteErrors(): void;
package/dist/errors.js ADDED
@@ -0,0 +1,34 @@
1
+ const EVENT = 'mfeflow:remote-error';
2
+ const CONNECTED = Symbol.for('mfeflow.telemetry.errors');
3
+ const isFunction = (value)=>'function' == typeof value;
4
+ function isRemoteReport(value) {
5
+ return 'object' == typeof value && null !== value && 'string' == typeof Reflect.get(value, 'mfe') && 'string' == typeof Reflect.get(value, 'where') && 'string' == typeof Reflect.get(value, 'message');
6
+ }
7
+ function connectRemoteErrors(report) {
8
+ if ("u" < typeof window || isFunction(Reflect.get(window, CONNECTED))) return ()=>{};
9
+ const forward = (item)=>{
10
+ if (!isRemoteReport(item)) return;
11
+ report(new Error(item.message), {
12
+ mfe: item.mfe,
13
+ where: item.where
14
+ });
15
+ };
16
+ const earlier = Reflect.get(globalThis, 'mfeflowReports');
17
+ if (Array.isArray(earlier)) for (const item of earlier)forward(item);
18
+ const onEvent = (event)=>{
19
+ forward(event instanceof CustomEvent ? event.detail : void 0);
20
+ };
21
+ window.addEventListener(EVENT, onEvent);
22
+ const disconnect = ()=>{
23
+ window.removeEventListener(EVENT, onEvent);
24
+ Reflect.deleteProperty(window, CONNECTED);
25
+ };
26
+ Reflect.set(window, CONNECTED, disconnect);
27
+ return disconnect;
28
+ }
29
+ function disconnectRemoteErrors() {
30
+ if ("u" < typeof window) return;
31
+ const disconnect = Reflect.get(window, CONNECTED);
32
+ if (isFunction(disconnect)) disconnect();
33
+ }
34
+ export { connectRemoteErrors, disconnectRemoteErrors };
@@ -0,0 +1,5 @@
1
+ export { flushTelemetry, getTracer, initTelemetry, isTelemetryReady, recordWebVitals, reportError, resetTelemetryForTests, telemetryReady, type InitOptions, type ProviderSource, } from './core.js';
2
+ export { connectRemoteErrors } from './errors.js';
3
+ export { createRecordingProvider, noopProvider, type RecordedTelemetry } from './noop.js';
4
+ export { MfeTracer } from './tracer.js';
5
+ export type { Attributes, ErrorContext, Span, SpanOptions, TelemetryProvider, Tracer, WebVitalMetric, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { flushTelemetry, getTracer, initTelemetry, isTelemetryReady, recordWebVitals, reportError, resetTelemetryForTests, telemetryReady } from "./core.js";
2
+ export { connectRemoteErrors } from "./errors.js";
3
+ export { createRecordingProvider, noopProvider } from "./noop.js";
4
+ export { MfeTracer } from "./tracer.js";
package/dist/noop.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ErrorContext, TelemetryProvider, WebVitalMetric } from './types.js';
2
+ export declare const noopProvider: TelemetryProvider;
3
+ export type RecordedTelemetry = {
4
+ type: 'error';
5
+ error: unknown;
6
+ context: ErrorContext;
7
+ } | {
8
+ type: 'web-vital';
9
+ metric: WebVitalMetric;
10
+ };
11
+ /** A provider for tests: it sends nothing and remembers errors and web vitals in order. */
12
+ export declare function createRecordingProvider(): TelemetryProvider & {
13
+ calls: RecordedTelemetry[];
14
+ };
package/dist/noop.js ADDED
@@ -0,0 +1,31 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ const noopProvider = {
3
+ name: 'noop',
4
+ init () {},
5
+ getTracer: (mfeName)=>trace.getTracer(mfeName),
6
+ reportError () {},
7
+ recordWebVitals () {}
8
+ };
9
+ function createRecordingProvider() {
10
+ const calls = [];
11
+ return {
12
+ name: 'recording',
13
+ calls,
14
+ init () {},
15
+ getTracer: (mfeName)=>trace.getTracer(mfeName),
16
+ reportError (error, context) {
17
+ calls.push({
18
+ type: 'error',
19
+ error,
20
+ context
21
+ });
22
+ },
23
+ recordWebVitals (metric) {
24
+ calls.push({
25
+ type: 'web-vital',
26
+ metric
27
+ });
28
+ }
29
+ };
30
+ }
31
+ export { createRecordingProvider, noopProvider };
package/dist/otel.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { type Context, type Span } from '@opentelemetry/api';
2
+ import { type ReadableSpan, type SpanExporter, type SpanProcessor } from '@opentelemetry/sdk-trace';
3
+ import type { TelemetryProvider } from './types.js';
4
+ export interface OtelOptions {
5
+ /** `service.name`, e.g. `storefront-shell`. */
6
+ serviceName: string;
7
+ /** `service.version`: the shell's build version. */
8
+ version: string;
9
+ /** `deployment.environment.name`, e.g. `prod`. */
10
+ environment: string;
11
+ /** Full OTLP/HTTP traces URL, e.g. `https://collector.example.com/v1/traces`. From the runtime config. */
12
+ otlpEndpoint: string;
13
+ /** Share of traces to record, 0 to 1. A trace that starts elsewhere keeps its parent's decision. Default 1. */
14
+ sampleRatio?: number | undefined;
15
+ /**
16
+ * Cross-origin URL prefixes that get a `traceparent` header on `fetch`, e.g. `https://api.example.com`.
17
+ * Same-origin requests always get it. The server must allow the header in CORS.
18
+ */
19
+ propagateTraceHeaderCorsUrls?: readonly string[] | undefined;
20
+ /**
21
+ * Keep the query string on `url.full`. Off by default: queries carry tokens and personal data.
22
+ * Userinfo (`user:pass@`) and the fragment are removed either way.
23
+ */
24
+ recordQuery?: boolean | undefined;
25
+ /** Record Core Web Vitals as spans. Default true. */
26
+ webVitals?: boolean | undefined;
27
+ /** How long spans wait for a batch, in milliseconds. Default 5000. */
28
+ exportDelayMs?: number | undefined;
29
+ /** Replaces the OTLP exporter, e.g. with a console exporter while developing. */
30
+ exporter?: SpanExporter | undefined;
31
+ }
32
+ /**
33
+ * A URL prefix as an anchored pattern (the fetch instrumentation compares plain strings for
34
+ * equality). A prefix that does not end in `/` must be followed by a path, a query, a fragment or the
35
+ * end, so `https://api.example.com` does not match `https://api.example.com.evil.test`.
36
+ */
37
+ export declare const prefixPattern: (prefix: string) => RegExp;
38
+ export declare function clampRatio(ratio: number | undefined): number;
39
+ /** Gives a span without `mfe.name` its parent's, so the `fetch` a remote makes inside its span carries the tag too. */
40
+ export declare class MfeNameProcessor implements SpanProcessor {
41
+ onStart(span: ReadableSpan & Span, parentContext: Context): void;
42
+ onEnd(): void;
43
+ forceFlush(): Promise<void>;
44
+ shutdown(): Promise<void>;
45
+ }
46
+ /** The longest message or stack a reported error may carry to the backend. */
47
+ export declare const MAX_ERROR_TEXT = 4096;
48
+ export declare function clip(text: string, max?: number): string;
49
+ /** Origin and path of a URL, plus the query when asked. Never userinfo, never the fragment. */
50
+ export declare function scrubUrl(value: string, recordQuery: boolean): string;
51
+ /**
52
+ * Rewrites the URL attributes of every finished span before the batch processor sees it, so a token
53
+ * in `?token=` or `user:pass@` never reaches the exporter. It runs on end, not in the fetch
54
+ * instrumentation's own hook, because that hook is skipped when the request fails early.
55
+ */
56
+ export declare class UrlScrubProcessor implements SpanProcessor {
57
+ private readonly recordQuery;
58
+ constructor(recordQuery: boolean);
59
+ onStart(): void;
60
+ onEnd(span: ReadableSpan): void;
61
+ forceFlush(): Promise<void>;
62
+ shutdown(): Promise<void>;
63
+ }
64
+ /**
65
+ * OpenTelemetry for the browser: one `TracerProvider` with the resource attributes, an OTLP/HTTP
66
+ * batch exporter, `fetch` tracing and Core Web Vitals. The shell loads this module after first
67
+ * render with a dynamic `import()`, so none of it is on the critical path.
68
+ *
69
+ * The context manager is the stack one: it follows synchronous calls, so a `fetch` started inside
70
+ * `startActiveSpan` is a child of that span. It does not follow `await` or timers; see the docs
71
+ * (zone.js was measured and does not follow native `await` either).
72
+ */
73
+ export declare function createOtelProvider(options: OtelOptions): TelemetryProvider;
package/dist/otel.js ADDED
@@ -0,0 +1,189 @@
1
+ import { SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
2
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
3
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
+ import { registerInstrumentations } from "@opentelemetry/instrumentation";
5
+ import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
6
+ import { resourceFromAttributes } from "@opentelemetry/resources";
7
+ import { BatchSpanProcessor, ParentBasedSampler, StackContextManager, TraceIdRatioBasedSampler, TracerProvider } from "@opentelemetry/sdk-trace";
8
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
9
+ const otel_escape = (text)=>text.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
10
+ const prefixPattern = (prefix)=>new RegExp(`^${otel_escape(prefix)}${prefix.endsWith('/') ? '' : '(?:$|[/?#])'}`);
11
+ function clampRatio(ratio) {
12
+ if (void 0 === ratio || Number.isNaN(ratio)) return 1;
13
+ return Math.min(1, Math.max(0, ratio));
14
+ }
15
+ function mfeNameOf(span) {
16
+ const attributes = span ? Reflect.get(span, 'attributes') : void 0;
17
+ return 'object' == typeof attributes && null !== attributes ? Reflect.get(attributes, 'mfe.name') : void 0;
18
+ }
19
+ class MfeNameProcessor {
20
+ onStart(span, parentContext) {
21
+ if (void 0 !== mfeNameOf(span)) return;
22
+ const name = mfeNameOf(trace.getSpan(parentContext));
23
+ if ('string' == typeof name) span.setAttribute('mfe.name', name);
24
+ }
25
+ onEnd() {}
26
+ forceFlush() {
27
+ return Promise.resolve();
28
+ }
29
+ shutdown() {
30
+ return Promise.resolve();
31
+ }
32
+ }
33
+ const URL_ATTRIBUTES = [
34
+ 'url.full',
35
+ 'http.url'
36
+ ];
37
+ const QUERY_ATTRIBUTES = [
38
+ 'url.query',
39
+ 'http.target'
40
+ ];
41
+ const MAX_ERROR_TEXT = 4096;
42
+ function clip(text, max = MAX_ERROR_TEXT) {
43
+ return text.length > max ? `${text.slice(0, max)}...[truncated]` : text;
44
+ }
45
+ function scrubUrl(value, recordQuery) {
46
+ try {
47
+ const url = new URL(value);
48
+ url.username = '';
49
+ url.password = '';
50
+ url.hash = '';
51
+ if (!recordQuery) url.search = '';
52
+ return url.href;
53
+ } catch {
54
+ const cut = value.search(/[?#]/);
55
+ return -1 === cut ? value : value.slice(0, cut);
56
+ }
57
+ }
58
+ class UrlScrubProcessor {
59
+ recordQuery;
60
+ constructor(recordQuery){
61
+ this.recordQuery = recordQuery;
62
+ }
63
+ onStart() {}
64
+ onEnd(span) {
65
+ const attributes = span.attributes;
66
+ for (const key of URL_ATTRIBUTES){
67
+ const value = attributes[key];
68
+ if ('string' == typeof value) attributes[key] = scrubUrl(value, this.recordQuery);
69
+ }
70
+ if (!this.recordQuery) {
71
+ for (const key of QUERY_ATTRIBUTES){
72
+ const value = attributes[key];
73
+ if ('string' == typeof value) {
74
+ const cut = value.indexOf('?');
75
+ if (-1 === cut) continue;
76
+ attributes[key] = value.slice(0, cut);
77
+ }
78
+ }
79
+ delete attributes['url.query'];
80
+ }
81
+ }
82
+ forceFlush() {
83
+ return Promise.resolve();
84
+ }
85
+ shutdown() {
86
+ return Promise.resolve();
87
+ }
88
+ }
89
+ async function watchWebVitals(record) {
90
+ const { onCLS, onFCP, onINP, onLCP, onTTFB } = await import("web-vitals");
91
+ const forward = (metric)=>{
92
+ record({
93
+ name: metric.name,
94
+ value: metric.value,
95
+ rating: metric.rating,
96
+ id: metric.id
97
+ });
98
+ };
99
+ onCLS(forward);
100
+ onFCP(forward);
101
+ onINP(forward);
102
+ onLCP(forward);
103
+ onTTFB(forward);
104
+ }
105
+ function createOtelProvider(options) {
106
+ const exporter = options.exporter ?? new OTLPTraceExporter({
107
+ url: options.otlpEndpoint
108
+ });
109
+ const batch = new BatchSpanProcessor({
110
+ exporter,
111
+ scheduledDelayMillis: options.exportDelayMs ?? 5000
112
+ });
113
+ const provider = new TracerProvider({
114
+ resource: resourceFromAttributes({
115
+ [ATTR_SERVICE_NAME]: options.serviceName,
116
+ [ATTR_SERVICE_VERSION]: options.version,
117
+ 'deployment.environment.name': options.environment
118
+ }),
119
+ sampler: new ParentBasedSampler({
120
+ root: new TraceIdRatioBasedSampler(clampRatio(options.sampleRatio))
121
+ }),
122
+ spanProcessors: [
123
+ new MfeNameProcessor(),
124
+ new UrlScrubProcessor(true === options.recordQuery),
125
+ batch
126
+ ]
127
+ });
128
+ const recordWebVitals = (metric)=>{
129
+ const span = provider.getTracer('web-vitals').startSpan(`web_vital.${metric.name}`, {
130
+ attributes: {
131
+ 'mfe.name': 'shell',
132
+ 'web_vital.name': metric.name,
133
+ 'web_vital.value': metric.value,
134
+ 'web_vital.rating': metric.rating,
135
+ 'web_vital.id': metric.id
136
+ }
137
+ });
138
+ span.end();
139
+ };
140
+ return {
141
+ name: 'otel',
142
+ init () {
143
+ trace.setGlobalTracerProvider(provider);
144
+ context.setGlobalContextManager(new StackContextManager().enable());
145
+ propagation.setGlobalPropagator(new W3CTraceContextPropagator());
146
+ registerInstrumentations({
147
+ tracerProvider: provider,
148
+ instrumentations: [
149
+ new FetchInstrumentation({
150
+ ignoreUrls: [
151
+ prefixPattern(options.otlpEndpoint)
152
+ ],
153
+ propagateTraceHeaderCorsUrls: (options.propagateTraceHeaderCorsUrls ?? []).map(prefixPattern)
154
+ })
155
+ ]
156
+ });
157
+ if (false !== options.webVitals) watchWebVitals(recordWebVitals).catch((error)=>{
158
+ console.error('[mfeflow/telemetry] web vitals are not recorded', error);
159
+ });
160
+ },
161
+ getTracer: (mfeName)=>provider.getTracer(mfeName),
162
+ reportError (error, errorContext) {
163
+ const span = provider.getTracer(errorContext.mfe).startSpan('mfe.error', {
164
+ attributes: {
165
+ 'mfe.name': errorContext.mfe,
166
+ ...void 0 === errorContext.where ? {} : {
167
+ 'mfe.error.where': errorContext.where
168
+ }
169
+ }
170
+ });
171
+ const message = clip(error instanceof Error ? error.message : String(error));
172
+ span.recordException({
173
+ name: error instanceof Error ? error.name : 'Error',
174
+ message,
175
+ ...error instanceof Error && void 0 !== error.stack ? {
176
+ stack: clip(error.stack)
177
+ } : {}
178
+ });
179
+ span.setStatus({
180
+ code: SpanStatusCode.ERROR,
181
+ message
182
+ });
183
+ span.end();
184
+ },
185
+ recordWebVitals,
186
+ flush: ()=>provider.forceFlush()
187
+ };
188
+ }
189
+ export { MAX_ERROR_TEXT, MfeNameProcessor, UrlScrubProcessor, clampRatio, clip, createOtelProvider, prefixPattern, scrubUrl };
@@ -0,0 +1,13 @@
1
+ import type { Attributes, Context, Span, SpanOptions, Tracer } from '@opentelemetry/api';
2
+ /**
3
+ * A tracer that looks up the real one on every call, so a tracer a remote took at import time
4
+ * starts recording once the provider is ready, and stamps `mfe.name` on every span it starts.
5
+ */
6
+ export declare class MfeTracer implements Tracer {
7
+ #private;
8
+ constructor(resolve: () => Tracer, tags: Attributes);
9
+ startSpan(name: string, options?: SpanOptions, context?: Context): Span;
10
+ startActiveSpan<F extends (span: Span) => ReturnType<F>>(name: string, fn: F): ReturnType<F>;
11
+ startActiveSpan<F extends (span: Span) => ReturnType<F>>(name: string, options: SpanOptions, fn: F): ReturnType<F>;
12
+ startActiveSpan<F extends (span: Span) => ReturnType<F>>(name: string, options: SpanOptions, context: Context, fn: F): ReturnType<F>;
13
+ }
package/dist/tracer.js ADDED
@@ -0,0 +1,28 @@
1
+ class MfeTracer {
2
+ #resolve;
3
+ #tags;
4
+ constructor(resolve, tags){
5
+ this.#resolve = resolve;
6
+ this.#tags = tags;
7
+ }
8
+ #tagged(options) {
9
+ return {
10
+ ...options,
11
+ attributes: {
12
+ ...this.#tags,
13
+ ...options?.attributes
14
+ }
15
+ };
16
+ }
17
+ startSpan(name, options, context) {
18
+ return this.#resolve().startSpan(name, this.#tagged(options), context);
19
+ }
20
+ startActiveSpan(name, a, b, c) {
21
+ const tracer = this.#resolve();
22
+ if ('function' == typeof a) return tracer.startActiveSpan(name, this.#tagged(void 0), a);
23
+ if ('function' == typeof b) return tracer.startActiveSpan(name, this.#tagged(a), b);
24
+ if (void 0 === b || void 0 === c) throw new TypeError('startActiveSpan needs a function to run inside the span');
25
+ return tracer.startActiveSpan(name, this.#tagged(a), b, c);
26
+ }
27
+ }
28
+ export { MfeTracer };
@@ -0,0 +1,24 @@
1
+ import type { Tracer } from '@opentelemetry/api';
2
+ export type { Attributes, Span, SpanOptions, Tracer } from '@opentelemetry/api';
3
+ export interface ErrorContext {
4
+ /** The remote the error came from. */
5
+ mfe: string;
6
+ /** Where it failed, e.g. `render`, `load`, `registry`. */
7
+ where?: string | undefined;
8
+ }
9
+ /** The fields of a web-vitals metric the platform records. */
10
+ export interface WebVitalMetric {
11
+ name: string;
12
+ value: number;
13
+ rating: 'good' | 'needs-improvement' | 'poor';
14
+ id: string;
15
+ }
16
+ export interface TelemetryProvider {
17
+ readonly name: string;
18
+ init(): void | Promise<void>;
19
+ getTracer(mfeName: string): Tracer;
20
+ reportError(error: unknown, context: ErrorContext): void;
21
+ recordWebVitals(metric: WebVitalMetric): void;
22
+ /** Sends what is buffered now. Tests call this instead of waiting for the batch timer. */
23
+ flush?: () => Promise<void>;
24
+ }
package/dist/types.js ADDED
File without changes
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@mfeflow/telemetry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Telemetry for micro-frontends behind one provider interface: a tracer per remote tagged with mfe.name, error reporting wired to remote failures, and an OpenTelemetry web provider loaded after first render.",
5
+ "keywords": [
6
+ "micro-frontend",
7
+ "mfe",
8
+ "module-federation",
9
+ "opentelemetry",
10
+ "tracing",
11
+ "web-vitals"
12
+ ],
13
+ "homepage": "https://mfeflow.github.io/mfeflow/",
14
+ "bugs": "https://github.com/mfeflow/mfeflow/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/mfeflow/mfeflow.git",
18
+ "directory": "packages/telemetry"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Sanjeev Sharma <speedsharma.ai@gmail.com>",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./otel": {
31
+ "types": "./dist/otel.d.ts",
32
+ "import": "./dist/otel.js",
33
+ "default": "./dist/otel.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22.12"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "dependencies": {
47
+ "@opentelemetry/api": "1.10.0",
48
+ "@opentelemetry/core": "3.0.0",
49
+ "@opentelemetry/exporter-trace-otlp-http": "0.300.0",
50
+ "@opentelemetry/instrumentation": "0.300.0",
51
+ "@opentelemetry/instrumentation-fetch": "0.300.0",
52
+ "@opentelemetry/resources": "3.0.0",
53
+ "@opentelemetry/sdk-trace": "3.0.0",
54
+ "@opentelemetry/semantic-conventions": "1.44.0",
55
+ "web-vitals": "6.2.3"
56
+ },
57
+ "devDependencies": {
58
+ "@rsbuild/plugin-react": "2.1.1",
59
+ "@rslib/core": "1.0.5",
60
+ "@types/node": "26.6.6",
61
+ "jsdom": "30.1.3",
62
+ "typescript": "7.0.2"
63
+ },
64
+ "mfeflow": {
65
+ "bucket": "singleton"
66
+ },
67
+ "scripts": {
68
+ "build": "rslib build",
69
+ "typecheck": "tsc --noEmit"
70
+ }
6
71
  }