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
13 changes: 13 additions & 0 deletions app/js_error_event.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
cwr('recordError', new Error('My error message'));
}

function recordCaughtErrorWithMetadata() {
cwr('recordError', {
error: new Error('My error message'),
metadata: { traceId: 'trace-1' }
});
}

function disable() {
cwr('disable');
}
Expand Down Expand Up @@ -96,6 +103,12 @@
<button id="recordCaughtError" onclick="recordCaughtError()">
Record caught error
</button>
<button
id="recordCaughtErrorWithMetadata"
onclick="recordCaughtErrorWithMetadata()"
>
Record caught error with metadata
</button>
<button id="resizeObserverLoopError" onclick="throwObserverLoopError()">
Throw ResizeObserverLoopError
</button>
Expand Down
1 change: 1 addition & 0 deletions docs/cdn_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Quick link:
<script>
cwr('recordPageView', '/home');
cwr('recordError', e);
cwr('recordError', { error: e, metadata: { traceId: 'abc' } });
cwr('recordEvent', { type: 'my_event', data: { k: 'v' } });
cwr('recordEvent', {
type: 'my_event',
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Each row shows the NPM method and the equivalent CDN command.
| Command | NPM | CDN | Description |
| --- | --- | --- | --- |
| **recordPageView** | `awsRum.recordPageView('/home')` | `cwr('recordPageView', '/home')` | Record a page view. Accepts a string (page ID) or a `PageView` object. See [PageView](#pageview). |
| **recordError** | `awsRum.recordError(e)` | `cwr('recordError', e)` | Record a caught error. Accepts `Error`, `ErrorEvent`, or string. |
| **recordError** | `awsRum.recordError(e)` <br> `awsRum.recordError(e, { traceId: 'abc' })` | `cwr('recordError', e)` <br> `cwr('recordError', { error, metadata })` | Record a caught error. Accepts `Error`, `ErrorEvent`, or string. The optional 2nd arg attaches per-call metadata, same rules and precedence as [`recordEvent` metadata](#metadata-precedence). On the CDN, an object literal with own `error` and `metadata` keys is read as an envelope; every other payload is recorded as the error itself. |
| **recordEvent** | `awsRum.recordEvent('type', { ... }, { tier: 'beta' })` | `cwr('recordEvent', { type, data, metadata })` | Record a custom event. The optional 3rd arg attaches per-call metadata (highest precedence). See [Event](#event). ⚠️ AppMonitor must have custom events enabled. |
| **addSessionAttributes** | `awsRum.addSessionAttributes({ appVersion: '1.3.8' })` | `cwr('addSessionAttributes', { appVersion: '1.3.8' })` | Add metadata attributes to every event in the current session. See [MetadataAttributes](../configuration.md#metadataattributes). |
| **setEventMetadataHook** | `awsRum.setEventMetadataHook((type, data, ctx) => ({ route: location.pathname }))` | _(NPM only — hooks are functions)_ | Register a function that decorates every recorded event's metadata. The hook receives `(eventType, eventData, currentMetadata)` and returns an `EventMetadata` object. Replaces any previously set hook. Manual metadata passed to `recordEvent` always wins. If the hook throws, its output is dropped for that event and the SDK logs a warning. |
Expand Down Expand Up @@ -117,7 +117,7 @@ Per-event metadata is composed of three layers, applied in order from lowest to

1. **Page attributes** — set via `recordPageView({ pageAttributes: ... })`.
2. **Hook output** — set via `setEventMetadataHook(fn)`. Overrides page attributes for non-reserved keys.
3. **Manual metadata** — passed as the 3rd argument to `recordEvent`. Overrides hook output.
3. **Manual metadata** — passed as the 3rd argument to `recordEvent` or the 2nd argument to `recordError`. Overrides hook output.

Session-level metadata travels in the request-level `Metadata` field, separate from per-event metadata. Within session-level metadata, precedence (lowest → highest) is:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,74 @@ describe('JsErrorPlugin tests', () => {
);
});

test('when the application records an error with metadata then the metadata is recorded on the error event', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin();

// Run
plugin.load(context);
plugin.record(new Error('Something went wrong!'), {
traceId: 'trace-1',
component: 'PaymentForm'
});
plugin.disable();

// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE);
expect(record.mock.calls[0][1]).toMatchObject({
version: '1.0.0',
type: 'Error',
message: 'Something went wrong!'
});
expect(record.mock.calls[0][2]).toEqual({
traceId: 'trace-1',
component: 'PaymentForm'
});
});

test('when no metadata is given then no event metadata is recorded', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin();

// Run
plugin.load(context);
plugin.record(new Error('Something went wrong!'));
plugin.disable();

// Assert
expect(record.mock.calls[0][2]).toBeUndefined();
});

test('when the application records an ErrorEvent with metadata then the error fields are preserved', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin();
const errorEvent = new ErrorEvent('error', {
colno: 1,
error: new Error('Something went wrong!'),
filename: 'main.js',
lineno: 2
});

// Run
plugin.load(context);
plugin.record(errorEvent, { traceId: 'trace-1' });
plugin.disable();

// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE);
expect(record.mock.calls[0][1]).toMatchObject({
version: '1.0.0',
type: 'Error',
message: 'Something went wrong!',
filename: 'main.js',
colno: 1,
lineno: 2
});
expect(record.mock.calls[0][2]).toEqual({ traceId: 'trace-1' });
});

test('when unhandledrejection error event outputs empty object as reason then message is recorded as undefined', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin();
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/plugins/InternalPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RUM_AWS_PREFIX } from './utils/constant';
import { Plugin } from './Plugin';
import { InternalPluginContext } from './types';
import { EventMetadata, InternalPluginContext } from './types';

export abstract class InternalPlugin<UpdateType = unknown>
implements Plugin<UpdateType>
Expand All @@ -23,7 +23,7 @@ export abstract class InternalPlugin<UpdateType = unknown>
this.context = context;
this.onload?.();
}
record?<D>(data: D): void;
record?(data: any, metadata?: EventMetadata): void;
update?(updateWith: UpdateType): void;

abstract enable(): void;
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/plugins/Plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PluginContext } from './types';
import { EventMetadata, PluginContext } from './types';

export interface Plugin<UpdateType = unknown> {
/**
Expand Down Expand Up @@ -29,8 +29,9 @@ export interface Plugin<UpdateType = unknown> {
* Manually record an event.
*
* @param data Data that the plugin will use to create an event.
* @param metadata Per-call metadata to attach to the recorded event.
*/
record?<D>(data: D): void;
record?(data: any, metadata?: EventMetadata): void;

/**
* Update the plugin.
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/plugins/PluginManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Plugin } from './Plugin';
import { PluginContext } from './types';
import { EventMetadata, PluginContext } from './types';
import { InternalPlugin } from './InternalPlugin';
import { InternalLogger } from '../utils/InternalLogger';

Expand Down Expand Up @@ -81,11 +81,12 @@ export class PluginManager {
*
* @param pluginId The unique identifier for the plugin being configured.
* @param data The data to be recorded by the plugin.
* @param metadata Per-call metadata to attach to the recorded event.
*/
public record(pluginId: string, data: any): void {
public record(pluginId: string, data: any, metadata?: EventMetadata): void {
const plugin = this.getPlugin(pluginId);
if (plugin?.record instanceof Function) {
plugin.record(data);
plugin.record(data, metadata);
} else {
throw new Error('AWS RUM Client record: Invalid plugin ID');
}
Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/plugins/event-plugins/JsErrorPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { InternalPlugin } from '../InternalPlugin';
import { JS_ERROR_EVENT_TYPE } from '../utils/constant';
import { errorEventToJsErrorEvent } from '../utils/js-error-utils';
import { EventMetadata } from '../types';

export const JS_ERROR_EVENT_PLUGIN_ID = 'js-error';

Expand Down Expand Up @@ -43,11 +44,14 @@ export class JsErrorPlugin extends InternalPlugin {
this.enabled = false;
}

record(error: any): void {
record(error: any, metadata?: EventMetadata): void {
if (error instanceof ErrorEvent) {
this.recordJsErrorEvent(error);
this.recordJsErrorEvent(error, metadata);
} else {
this.recordJsErrorEvent({ type: 'error', error } as ErrorEvent);
this.recordJsErrorEvent(
{ type: 'error', error } as ErrorEvent,
metadata
);
}
}

Expand All @@ -70,10 +74,11 @@ export class JsErrorPlugin extends InternalPlugin {
}
};

private recordJsErrorEvent(error: ErrorEvent) {
private recordJsErrorEvent(error: ErrorEvent, metadata?: EventMetadata) {
this.context?.record(
JS_ERROR_EVENT_TYPE,
errorEventToJsErrorEvent(error, this.config.stackTraceLength)
errorEventToJsErrorEvent(error, this.config.stackTraceLength),
metadata
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@ test.describe('JSErrorEvent Plugin', () => {
expect(eventDetails.message).toContain('My error message');
});

test('when the application records a caught error with metadata then the metadata is on the event', async ({
page
}) => {
await page.goto('/js_error_event.html');

await page.waitForTimeout(300);
await page.click('#recordCaughtErrorWithMetadata');
await page.click('#dispatch');

await expect(page.locator('#request_body')).toContainText('BatchId');

const requestBodyText = await page
.locator('#request_body')
.textContent();
const events = JSON.parse(requestBodyText || '{}').RumEvents.filter(
(e: any) => e.type === JS_ERROR_EVENT_TYPE
);

const eventDetails = JSON.parse(events[0].details);
const eventMetadata = JSON.parse(events[0].metadata);

expect(events.length).toBe(1);
expect(eventDetails.message).toContain('My error message');
expect(eventMetadata.traceId).toBe('trace-1');
});

test('when ignore function matches error then the plugin does not record the error', async ({
page
}) => {
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/sessions/VirtualPageLoadTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ export class VirtualPageLoadTimer extends MonkeyPatched<

private config: Config;
private pageManager: PageManager;
// Not named `record`: MonkeyPatched inherits an optional public `record` from InternalPlugin,
// and a private field of the same name is an illegal override.
// @ts-ignore
private readonly record: RecordEvent;
private readonly recordEvent: RecordEvent;

constructor(pageManager: PageManager, config: Config, record: RecordEvent) {
super('virtual-page-load-timer');
Expand All @@ -75,7 +77,7 @@ export class VirtualPageLoadTimer extends MonkeyPatched<

this.config = config;
this.pageManager = pageManager;
this.record = record;
this.recordEvent = record;
this.enable();

// Start tracking the timestamps
Expand Down Expand Up @@ -275,8 +277,8 @@ export class VirtualPageLoadTimer extends MonkeyPatched<
startTime: page.start,
duration: this.latestEndTime - page.start
};
if (this.record) {
this.record(
if (this.recordEvent) {
this.recordEvent(
PERFORMANCE_NAVIGATION_EVENT_TYPE,
virtualPageNavigationEvent
);
Expand Down
62 changes: 62 additions & 0 deletions packages/slim/__tests__/CommandQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,66 @@ describe('Slim CommandQueue tests', () => {
await cq.push({ c: 'startSession', p: null });
expect(startSession).not.toHaveBeenCalled();
});

test('recordError forwards a bare error', async () => {
const cq = new CommandQueue();
await cq.init(createAwsRumInit());
const error = new Error('test');
await cq.push({ c: 'recordError', p: error });
expect(recordError).toHaveBeenCalledWith(error);
});

test('recordError forwards metadata from an envelope payload', async () => {
const cq = new CommandQueue();
await cq.init(createAwsRumInit());
const error = new Error('test');
await cq.push({
c: 'recordError',
p: { error, metadata: { traceId: 'trace-1' } }
});
expect(recordError).toHaveBeenCalledWith(error, {
traceId: 'trace-1'
});
});

test('recordError treats an error-shaped object without metadata as the error', async () => {
const cq = new CommandQueue();
await cq.init(createAwsRumInit());
const thrown = { error: 'inner', message: 'real error message' };
await cq.push({ c: 'recordError', p: thrown });
expect(recordError).toHaveBeenCalledWith(thrown);
});

test('recordError treats a thrown Error carrying both envelope keys as the error', async () => {
const cq = new CommandQueue();
await cq.init(createAwsRumInit());
const thrown = Object.assign(new Error('real error message'), {
error: 'inner',
metadata: { fromError: true }
});
await cq.push({ c: 'recordError', p: thrown });
expect(recordError).toHaveBeenCalledWith(thrown);
});

test('recordError does not treat an inherited metadata key as an envelope', async () => {
const cq = new CommandQueue();
await cq.init(createAwsRumInit());
class ThrownError extends Error {}
(ThrownError.prototype as any).error = 'inner';
(ThrownError.prototype as any).metadata = { fromProto: true };
const thrown = new ThrownError('real error message');
await cq.push({ c: 'recordError', p: thrown });
expect(recordError).toHaveBeenCalledWith(thrown);
});

test('recordError rejects non-object metadata', async () => {
const cq = new CommandQueue();
await cq.init(createAwsRumInit());
await expect(
cq.push({
c: 'recordError',
p: { error: new Error('test'), metadata: 'oops' }
})
).rejects.toThrow('IncorrectParametersException');
});
});
15 changes: 14 additions & 1 deletion packages/slim/__tests__/orchestration/Orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,20 @@ describe('Slim Orchestration tests', () => {
orch.recordError(error);
expect(pluginRecord).toHaveBeenCalledWith(
'com.amazonaws.rum.js-error',
error
error,
undefined
);
});

test('recordError forwards metadata to pluginManager', async () => {
const orch = new Orchestration('a', 'c', 'us-east-1', {});
const error = new Error('test');
const metadata = { traceId: 'trace-1' };
orch.recordError(error, metadata);
expect(pluginRecord).toHaveBeenCalledWith(
'com.amazonaws.rum.js-error',
error,
metadata
);
});

Expand Down
Loading