Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions projects/ngx-ui-watchtower/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Everything each entry point exports, by role.
| Setup | `provideUwtTelemetry`, `withLogging`, `withScenarios`, `withBIEvents`, `withRedaction`, `UwtTelemetryFeature`, `provideUwtTelemetrySink`, `UwtTelemetryLocalSinkMode` (`'broadcast' \| 'noop'`), `provideUwtTelemetryBroadcastHost`, `provideUwtTelemetryDestination` + `UwtTelemetryDestinationOptions` |
| Configuration | `UWT_TELEMETRY_IDENTITY` + `UwtTelemetryIdentity`; `UWT_LOG_CONFIG` + `UwtLogConfig`; `UWT_SCENARIO_TELEMETRY_CONFIG` + `UwtScenarioTelemetryConfig`; `UWT_BI_TELEMETRY_CONFIG` + `UwtBITelemetryConfig`; `UWT_REDACT_CONFIG`; `UWT_DEFAULT_TELEMETRY_CONFIG` (every default) |
| Scenarios | `UwtScenarioTelemetryService`, `UwtScenario`, `traceScenario` + `UwtTraceScenarioOptions`; models `UwtScenarioOptions`, `UwtScenarioOutcome`, `UwtScenarioStepDetail`, `UwtScenarioRecord`, `UwtScenarioStep`, `UwtScenarioStepEvent`, `UwtScenarioAggregate`, `UwtScenarioStatus`, `UwtScenarioStepStatus` |
| BI events | `UwtBITelemetryService`; `UwtBIEvent`, `UwtBIEventDetail` (`eventType` override, `scenarioId`, `feature`) |
| BI events | `UwtBITelemetryService`; `UwtBIEvent`, `UwtBIEventDetail` (`scenarioId`, `feature`) |
| Logging | `UwtLoggerService`, `UwtLogger`; `UwtLogDetail`, `UwtLogRecord`, `UwtLogLevel`, `UWT_LOG_LEVEL_ORDER`; `UWT_LOG_API_PROVIDER`, `UwtLogApiProvider`, `UwtLogQuery`; `UwtNoopLogApiProvider`, `UwtBroadcastLogApiProvider` |
| Name registries | `UwtScenarioNames` / `UwtScenarioName`, `UwtScenarioSteps` / `UwtStepName`, `UwtBIEventNames` / `UwtBIEventName`, `UwtLoggerNames` / `UwtLoggerName` |
| Sinks | `UwtTelemetrySink` (abstract), `UwtNoopTelemetrySink`, `UwtBroadcastTelemetrySink`, `UwtTelemetryBroadcastHost`, `UWT_BROADCAST_CHANNEL`, `UWT_DEFAULT_BROADCAST_CHANNEL` (`'ngx-ui-watchtower'`); `uwtClassifyTelemetryEvent` + `UwtTelemetrySinkEvent`, for a sink to tell what it received |
Expand Down Expand Up @@ -167,9 +167,8 @@ the helper and the check. No destination at all still fails with `NG0201`.
`uwtClassifyTelemetryEvent(eventType, payload)` tells `record` what it got,
as a discriminated union — `scenario` (a `UwtScenarioRecord`),
`scenario-step` (a `UwtScenarioStepEvent`), `bi` (a `UwtBIEvent`) or
`unknown` (an event-type override). It matches on the event type's ending,
so it works for any `eventNamespace` and for forwarded events; the broadcast
host uses it too.
`unknown` (any other event type). It matches on the event type's ending,
so it works for any `eventNamespace` and for forwarded events.

What a destination does **not** need to do: redact (payloads arrive already
redacted), deduplicate BI events, time anything, or guard the library's
Expand Down Expand Up @@ -480,20 +479,10 @@ without the envelope. `sessionId` is absent until the RUM client initializes,

#### One event type

All BI events share a single event type, with `eventName` carried as a field
— one schema to query, one extended-metric definition. If an existing
dashboard is keyed on a specific legacy type, a single event can override it:

```ts
biTelemetry.track(
'click',
{ source: 'toolbar' },
{ eventType: 'com.my-app.click' }
);
```

Use that only for migration — giving every event its own type is exactly what
the single-type design avoids.
All BI events share a single event type, `{ns}.bi`, with `eventName` carried
as a field — one schema to query, one extended-metric definition. There is no
per-event override: giving events their own types is exactly what the
single-type design avoids.

### Fields deliberately left out

Expand Down Expand Up @@ -1629,7 +1618,7 @@ biTelemetry.track(

Identical events within 400ms are collapsed into one, absorbing double-fires
from a handler bound to both `click` and `keydown`. "Identical" means the same
name, scenario correlation, event type, feature and metadata content;
name, scenario correlation, feature and metadata content;
differing in any one is a distinct event.

### Logging
Expand Down Expand Up @@ -2338,11 +2327,13 @@ and `location`, but not `top`, `BroadcastChannel` or storage — which is why
this works there.

**Where the shell owns the fragment's URL** — a plain iframe — a query
parameter does the same job:
parameter does the same job. The iframe must be same-origin with the shell,
like every fragment; a sourceless iframe (`about:blank`, `srcdoc`) qualifies
but has no URL to carry the parameter, so it reads the `top` global instead.

```ts
// shell
iframe.src = `https://fragment.example.com/?channel=${channelId}`;
iframe.src = `/fragment/?channel=${channelId}`;

// fragment
const channelId = new URLSearchParams(window.location.search).get('channel');
Expand Down Expand Up @@ -2415,7 +2406,7 @@ whatever the destination.
this.logger.emit({
eventName: eventType,
body: event.payload.eventName,
attributes: { ...event.payload.metadata }
attributes: { ...event.payload.metadata, ...this.identity() }
});
}
}
Expand All @@ -2426,7 +2417,8 @@ whatever the destination.
startTime: new Date(start),
attributes: {
'uwt.scenario.id': record.scenarioId,
'uwt.scenario.status': record.status
'uwt.scenario.status': record.status,
...this.identity()
}
});
const parent = trace.setSpan(context.active(), root);
Expand All @@ -2449,10 +2441,20 @@ whatever the destination.
this.logger.emit({
eventName: 'exception',
severityNumber: SeverityNumber.ERROR,
body: error.message
body: error.message,
attributes: this.identity()
});
}

// Per record, not a resource attribute: resources are fixed at SDK startup,
// and the session changes on sign-out.
private identity(): Record<string, string> {
return {
'session.id': this.sessionId,
...(this.userId ? { 'user.id': this.userId } : {})
};
}

getSessionId() {
return this.sessionId;
}
Expand Down Expand Up @@ -2514,6 +2516,11 @@ whatever the destination.
linked by `uwt.scenario.id` / `correlationId` — as it is with RUM today.
Joining them by trace id would need scenarios to start spans in
`start()`, which is a change to the core, not to the sink.
- **A session is one page load.** The sink's `sessionId` lives in memory, so a
reload or a second tab starts a new session — unlike RUM's cookie session,
which tabs share and reloads keep. Expect more, shorter sessions than RUM
reports; to match it, keep the id in a cookie with an inactivity expiry. There
is no anonymous user id either: until sign-in, only `session.id` is set.
- **The browser logs SDK is still experimental.** Pin exact versions and
re-run the tests after every upgrade: a mis-configured processor exports
nothing, without an error.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface UwtBITelemetryConfig {
/**
* Distinct event keys tracked before expired ones are swept.
*
* Keyed per `eventName|scenarioId|eventType|feature|metadata`, which
* Keyed per `eventName|scenarioId|feature|metadata`, which
* grows unbounded over a long session without this cap.
*/
dedupMaxKeys: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,34 +40,19 @@ export interface UwtBIEventNames {}
export type UwtBIEventName = UwtRegistered<UwtBIEventNames>;

/**
* Correlation fields carried through unchanged onto the emitted
* {@link UwtBIEvent} — shared by {@link UwtBIEventDetail} and
* {@link UwtBIEvent} so neither declares its own copy.
* Optional correlation detail for a business/UX event, carried through
* unchanged onto the emitted {@link UwtBIEvent}.
*
* @group Interfaces
*/
export interface UwtBIEventCorrelation {
export interface UwtBIEventDetail {
/** Scenario this event happened inside. Pass {@link UwtScenario.id}. */
scenarioId?: string;

/** Product area, e.g. `customers`. Scrubbed the same as any other string value. */
feature?: string;
}

/**
* Optional correlation detail for a business/UX event.
*
* @group Interfaces
*/
export interface UwtBIEventDetail extends UwtBIEventCorrelation {
/**
* Overrides the RUM event type for this one event. BI events normally
* share one type, with `eventName` as a field. Use this only when an
* existing dashboard or metric filter needs a specific event type.
*/
eventType?: string;
}

/**
* A discrete business or UX event.
*
Expand All @@ -79,8 +64,7 @@ export interface UwtBIEventDetail extends UwtBIEventCorrelation {
*
* @group Interfaces
*/
export interface UwtBIEvent
extends UwtBIEventCorrelation, UwtTelemetryAttribution {
export interface UwtBIEvent extends UwtBIEventDetail, UwtTelemetryAttribution {
/**
* Event name, e.g. `export_clicked`. Declared by the application in
* {@link UwtBIEventNames} — this library never hardcodes business event
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,13 +343,6 @@ describe('UwtBITelemetryService', () => {
expect(sink.ofType(UWT_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2);
});

it('should not collapse the same event across different event types', () => {
service.track('export_clicked', undefined, { eventType: 'com.uwt.a' });
service.track('export_clicked', undefined, { eventType: 'com.uwt.b' });

expect(sink.events).toHaveLength(2);
});

it('should allow the event again once the window has passed', () => {
const nowSpy = jest.spyOn(performance, 'now');
nowSpy.mockReturnValue(1_000_000);
Expand Down Expand Up @@ -418,9 +411,9 @@ describe('UwtBITelemetryService', () => {
expect(sink.ofType(UWT_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2);
});

it('should not collapse two events whose eventType/feature only collide under naive delimiter joining', () => {
service.track('checkout', undefined, { eventType: 'x|y', feature: 'z' });
service.track('checkout', undefined, { eventType: 'x', feature: 'y|z' });
it('should not collapse two events whose scenarioId/feature only collide under naive delimiter joining', () => {
service.track('checkout', undefined, { scenarioId: 'x|y', feature: 'z' });
service.track('checkout', undefined, { scenarioId: 'x', feature: 'y|z' });

expect(sink.events).toHaveLength(2);
});
Expand Down Expand Up @@ -678,22 +671,24 @@ describe('UwtBITelemetryService', () => {
expect(namespacedSink.events[0].eventType).toBe('com.test-app.bi');
});

it('should let one event override the type, for a legacy dashboard', () => {
service.track(
'click',
{ source: 'toolbar' },
{
eventType: 'com.test-app.click'
}
);
it('should always use the namespace type, even when a caller still passes an eventType', () => {
service.track('click', undefined, {
eventType: 'com.test-app.click'
} as never);

expect(sink.events[0].eventType).toBe('com.test-app.click');
expect(sink.events[0].eventType).toBe('com.uwt.bi');
expect(sink.events[0].payload.eventName).toBe('click');
});

it('should fall back to the configured type when the override is empty', () => {
service.track('click', undefined, { eventType: '' });
expect(sink.events[0].eventType).toBe('com.uwt.bi');
it('should collapse identical events that differ only in a legacy eventType', () => {
service.track('click', undefined, {
eventType: 'com.test-app.a'
} as never);
service.track('click', undefined, {
eventType: 'com.test-app.b'
} as never);

expect(sink.events).toHaveLength(1);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class UwtBITelemetryService {
* as declared in {@link UwtBIEventNames}. Treat it as a metric dimension:
* keep the cardinality low and never interpolate an identifier into it.
* @param metadata flat attributes describing the interaction
* @param detail optional scenario correlation, feature and event-type override
* @param detail optional scenario correlation and feature
*/
track(
eventName: UwtBIEventName,
Expand Down Expand Up @@ -130,13 +130,11 @@ export class UwtBITelemetryService {
)
};

const eventType = detail?.eventType || this.eventTypes.bi;

uwtDebugWrite('debugBI', () =>
writeToConsole(eventName, eventType, event)
writeToConsole(eventName, this.eventTypes.bi, event)
);

this.sink.record(eventType, event);
this.sink.record(this.eventTypes.bi, event);
});
}

Expand All @@ -148,7 +146,6 @@ export class UwtBITelemetryService {
const key = JSON.stringify([
eventName,
detail?.scenarioId ?? '',
detail?.eventType ?? '',
detail?.feature ?? '',
this.metadataKey(metadata)
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import type {
* What a sink was handed, told apart by kind — the result of
* {@link uwtClassifyTelemetryEvent}.
*
* `unknown` is an event type this library doesn't emit itself, such as a
* `UwtBIEventDetail.eventType` override.
* `unknown` is an event type this library doesn't emit itself.
*
* @group Types
*/
Expand Down
Loading