diff --git a/e2e/core/mocks/transport.ts b/e2e/core/mocks/transport.ts new file mode 100644 index 0000000..7cd18d5 --- /dev/null +++ b/e2e/core/mocks/transport.ts @@ -0,0 +1,26 @@ +import type { + Transport, + TransportRequest, + TransportResponse +} from "@trackjs/core"; + +// Mock transport for testing +export class MockTransport implements Transport { + public sentRequests: TransportRequest[] = []; + public shouldFail = false; + + async send(request: TransportRequest): Promise { + if (this.shouldFail) { + throw new Error("Transport error"); + } + this.sentRequests.push(request); + return { + status: 200 + }; + } + + reset() { + this.sentRequests = []; + this.shouldFail = false; + } +} \ No newline at end of file diff --git a/e2e/core/node-compatibility.test.ts b/e2e/core/node-compatibility.test.ts deleted file mode 100644 index 4fa0e07..0000000 --- a/e2e/core/node-compatibility.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { TrackJS } from "@trackjs/core"; -import { test, expect, beforeEach } from "vitest"; -import type { Transport, TransportRequest, TransportResponse } from "@trackjs/core"; - -// Mock transport for testing -class MockTransport implements Transport { - public sentRequests: TransportRequest[] = []; - - async send(request: TransportRequest): Promise { - this.sentRequests.push(request); - return { - status: 200 - }; - } - - reset() { - this.sentRequests = []; - } -} - -beforeEach(() => { - TrackJS.destroy(); -}); - -test('TrackJS.install({...}) with minimum options', () => { - expect(() => { - TrackJS.initialize({ - token: "test token" - }); - }).not.toThrow(); - expect(TrackJS.isInitialized()).toBe(true); -}); - -test('TrackJS.track() can track errors after install', async () => { - const mockTransport = new MockTransport(); - - TrackJS.initialize({ - token: 'test token', - transport: mockTransport - }); - - // Track different error types - await TrackJS.track('String error'); - await TrackJS.track(new Error('Error object')); - await TrackJS.track({ custom: 'object' }); - - // Verify requests were sent - expect(mockTransport.sentRequests).toHaveLength(3); - - // Verify the structure of a request - const firstRequest = mockTransport.sentRequests[0] as TransportRequest; - expect(firstRequest.method).toBe('POST'); - expect(firstRequest.url).toBe('https://capture.trackjs.com/capture/node?token=test%20token&v=core-0.0.0'); - expect(JSON.parse(firstRequest.data as string)).toHaveProperty('message', '"String error"'); -}); - -test('TrackJS.track() with custom metadata', async () => { - const mockTransport = new MockTransport(); - - TrackJS.initialize({ - token: 'test token', - transport: mockTransport, - metadata: { - "foo": "bar" - } - }); - - await TrackJS.track(new Error('Oops'), { metadata: { "bar": "baz" }}); - - // Verify requests were sent - expect(mockTransport.sentRequests).toHaveLength(1); - - // Verify the structure of a request - const request = mockTransport.sentRequests[0] as TransportRequest; - const payload = JSON.parse(request.data as string); - - expect(payload).toMatchObject({ - metadata: [ - { key: "foo", value: "bar" }, - { key: "bar", value: "baz" } - ] - }); -}) - - - diff --git a/e2e/core/node-integration.test.js b/e2e/core/node-integration.test.js new file mode 100644 index 0000000..cd56003 --- /dev/null +++ b/e2e/core/node-integration.test.js @@ -0,0 +1,129 @@ +/** + * Test integration with the NodeJS environment with plain JavaScript. + * Focused on sending objects that typescript would not allow + */ + +import { test, expect, beforeEach } from "vitest"; +import { TrackJS, timestamp } from "@trackjs/core"; +import { MockTransport } from "./mocks/transport"; + +beforeEach(() => { + TrackJS.destroy(); +}); + +test("TrackJS basic functionality", () => { + const transport = new MockTransport(); + + TrackJS.initialize({ + token: "test-token", + transport + }); + + TrackJS.addMetadata({ + "foo": "bar" + }); + + TrackJS.addTelemetry("con", { + timestamp: timestamp(), + severity: "info", + message: "test message" + }); + + const networkTelemetry = { + type: "fetch", + startedOn: timestamp(), + method: "POST", + url: "https://example.com/path" + }; + TrackJS.addTelemetry("net", networkTelemetry); + networkTelemetry.completedOn = timestamp(); + networkTelemetry.statusCode = 403; + networkTelemetry.statusText = "Forbidden"; + + TrackJS.track(new Error("oops"), { + entry: "catch", + metadata: { + "bar": "baz" + } + }); + + expect(transport.sentRequests.length).toBe(1); + expect(JSON.parse(transport.sentRequests[0].data)).toMatchObject({ + entry: "catch", + message: "oops", + stack: expect.any(String), + metadata: [ + { key: "foo", value: "bar"}, + { key: "bar", value: "baz" } + ], + console: [ + { + timestamp: expect.any(String), + severity: "info", + message: "test message", + } + ], + network: [ + { + type: "fetch", + startedOn: expect.any(String), + method: "POST", + url: "https://example.com/path", + completedOn: expect.any(String), + statusCode: 403, + statusText: "Forbidden" + } + ] + }); +}); + +test("TrackJS with missing token", () => { + expect(() => TrackJS.initialize()).toThrowError("TrackJS token is required"); +}); + +test("TrackJS double initialized", () => { + expect(() => TrackJS.initialize({ token: "test-token" })).not.toThrowError(); + expect(() => TrackJS.initialize({ token: "test-token" })).toThrowError("TrackJS is already initialized"); +}); + +test("TrackJS.addMetadata with non-strings", () => { + const transport = new MockTransport(); + TrackJS.initialize({ token: "test-token", transport }); + TrackJS.addMetadata({ + [Symbol("test")]: { "foo": "bar" }, + 42: false + }); + TrackJS.track("test"); + expect(JSON.parse(transport.sentRequests[0].data)).toMatchObject({ + metadata: [ + { key: "42", value: "false" } + ] + }); +}); + +test("TrackJS.addTelemetry with invalid shape", () => { + const transport = new MockTransport(); + TrackJS.initialize({ token: "test-token", transport }); + + // add console with wrong type key + expect(() => { + TrackJS.addTelemetry("net", { + timestamp: timestamp(), + severity: "log", + message: "test message" + }); + }).toThrow(); + + // add garbage to console + expect(() => { + TrackJS.addTelemetry("con", { + foo: "bar" + }); + }).toThrow(); + + TrackJS.track("test"); + expect(JSON.parse(transport.sentRequests[0].data)).toMatchObject({ + console: [], + network: [] + }); +}); diff --git a/e2e/core/node-integration.test.ts b/e2e/core/node-integration.test.ts new file mode 100644 index 0000000..b239791 --- /dev/null +++ b/e2e/core/node-integration.test.ts @@ -0,0 +1,125 @@ +import { TrackJS, timestamp } from "@trackjs/core"; +import { test, expect, beforeEach } from "vitest"; +import { MockTransport } from "./mocks/transport"; +import type { NetworkTelemetry, Transport, TransportRequest, TransportResponse } from "@trackjs/core"; + +beforeEach(() => { + TrackJS.destroy(); +}); + +test('TrackJS.install({...}) with minimum options', () => { + TrackJS.initialize({ + token: "test-token", + }); + expect(TrackJS.isInitialized()).toBe(true); +}); + +test('TrackJS.track() can track errors after install', async () => { + const transport = new MockTransport(); + + TrackJS.initialize({ + token: 'test-token', + transport + }); + + // Track different error types + await TrackJS.track('String error'); + await TrackJS.track(new Error('Error')); + await TrackJS.track({ custom: 'object' }); + + // Verify requests were sent + expect(transport.sentRequests).toHaveLength(3); + + // Verify the structure of a request + const firstRequest = transport.sentRequests[0] as TransportRequest; + expect(firstRequest.method).toBe('POST'); + expect(firstRequest.url).toBe('https://capture.trackjs.com/capture/node?token=test-token&v=core-0.0.0'); + + expect(JSON.parse(firstRequest.data as string)).toMatchObject({ + message: '"String error"', + stack: expect.any(String) + }); + expect(JSON.parse(transport.sentRequests[1]?.data as string)).toMatchObject({ + message: 'Error', + stack: expect.any(String) + }); + expect(JSON.parse(transport.sentRequests[2]?.data as string)).toMatchObject({ + message: '{"custom":"object"}', + stack: expect.any(String) + }); +}); + +test('TrackJS.track() with custom metadata', async () => { + const transport = new MockTransport(); + + TrackJS.initialize({ + token: 'test token', + transport, + metadata: { + "foo": "bar" + } + }); + + await TrackJS.track(new Error('Oops'), { metadata: { "bar": "baz" }}); + + expect(transport.sentRequests).toHaveLength(1); + expect(JSON.parse(transport.sentRequests[0]?.data as string)).toMatchObject({ + metadata: [ + { key: "foo", value: "bar" }, + { key: "bar", value: "baz" } + ] + }); +}) + +test('TrackJS.addTelemetry(...) sends telemetry', async () => { + const transport = new MockTransport(); + + TrackJS.initialize({ + token: 'test token', + transport + }); + + TrackJS.addTelemetry("con", { + timestamp: timestamp(), + severity: "info", + message: "test message" + }); + + let networkTelemetry: NetworkTelemetry = { + type: "fetch", + method: "POST", + url: "https://example.com/path", + startedOn: timestamp() + }; + + TrackJS.addTelemetry("net", networkTelemetry); + + networkTelemetry.completedOn = timestamp(); + networkTelemetry.statusCode = 404; + networkTelemetry.statusText = "NOT FOUND"; + + await TrackJS.track(new Error('Oops')); + + expect(transport.sentRequests).toHaveLength(1); + expect(JSON.parse(transport.sentRequests[0]?.data as string)).toMatchObject({ + console: [ + { + timestamp: expect.any(String), + severity: "info", + message: "test message" + } + ], + network: [ + { + type: "fetch", + method: "POST", + url: "https://example.com/path", + startedOn: expect.any(String), + completedOn: expect.any(String), + statusCode: 404, + statusText: "NOT FOUND" + } + ] + }); +}); + diff --git a/e2e/core/package.json b/e2e/core/package.json index dcd5718..45c23eb 100644 --- a/e2e/core/package.json +++ b/e2e/core/package.json @@ -5,7 +5,8 @@ "private": true, "type": "module", "scripts": { - "test": "vitest run" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@trackjs/core": "file:../../packages/core" diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index bd5e934..ed00ca7 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,18 +1,24 @@ import { Metadata } from "./metadata"; +import { TelemetryLog } from "./telemetryLog"; +import { timestamp, serialize, isError } from "./utils"; + import type { CapturePayload, Options, + Telemetry, + TelemetryType, TrackOptions } from "./types"; -import { timestamp, serialize, isError } from "./utils"; export class Client { private options: Options; private metadata: Metadata; + private telemetry: TelemetryLog; constructor(options: Options) { this.options = options; this.metadata = new Metadata(this.options.metadata); + this.telemetry = new TelemetryLog(); } public addMetadata(metadata: Record): void { @@ -23,9 +29,10 @@ export class Client { this.metadata.remove(metadata); } - /** - * Track an error and send it to TrackJS - */ + public addTelemetry(type: TelemetryType, telemetry: Telemetry): void { + this.telemetry.add(type, telemetry); + } + public async track(error: Error | object | string, options?: Partial): Promise { const safeOptions: TrackOptions = { entry: "direct", @@ -33,20 +40,13 @@ export class Client { ...options }; - const safeError = isError(error) ? error as Error : new Error(this._serialize(error)) + const safeError = isError(error) ? error as Error : new Error(serialize(error)) const payload = this._createPayload(safeError, safeOptions); await this._send(payload); } - _serialize(thing: any): string { - return serialize(thing, { - depth: 3, - handlers: this.options.serializer - }); - } - /** * Create a complete payload for an error */ @@ -87,10 +87,10 @@ export class Client { metadata: payloadMetadata.get(), - console: [], - nav: [], - network: [], - visitor: [], + console: this.telemetry.get("con"), + nav: this.telemetry.get("nav"), + network: this.telemetry.get("net"), + visitor: this.telemetry.get("vis"), agentPlatform: "", version: '0.0.0', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9027ff9..a95ba72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ export * as TrackJS from './trackjs'; -export { uuid } from './utils/'; +export { timestamp, uuid } from './utils/'; export type * from './types/'; \ No newline at end of file diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index c0307eb..4ae8d24 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -1,8 +1,6 @@ import { truncate } from "./utils"; -// The max length that the server will accept is 500. We pad this down by 10 so -// that there is room for the …{9999} to know the true length. -const MAX_METADATA_LENGTH = 490; +const MAX_METADATA_LENGTH = 500; export class Metadata { diff --git a/packages/core/src/telemetryLog.ts b/packages/core/src/telemetryLog.ts new file mode 100644 index 0000000..31b4e5c --- /dev/null +++ b/packages/core/src/telemetryLog.ts @@ -0,0 +1,96 @@ +import type { ConsoleTelemetry, NavigationTelemetry, NetworkTelemetry, Telemetry, TelemetryType, VisitorTelemetry } from "./types"; +import { truncate } from "./utils"; + +const MAX_LOG_SIZE = 30; +const MAX_MESSAGE_LENGTH = 10_000; +const MAX_URI_LENGTH = 1_000; +const MAX_ATTRIBUTES = 20; +const MAX_ATTRIBUTE_VALUE = 500; + +export class TelemetryLog { + + private store: Array<{ type: TelemetryType, telemetry: Telemetry}> = []; + + public add(type: TelemetryType, telemetry: Telemetry): void { + // Type and Telemetry are guaranteed to exist at this point, but the telemetry + // might be the wrong shape. Normalize the properties and prevent huge values. + switch (type) { + case "con": + telemetry = _normalizeConsoleTelemetry(telemetry); + break; + + case "nav": + telemetry = _normalizeNavigationTelemetry(telemetry); + break; + + case "net": + telemetry = _normalizeNetworkTelemetry(telemetry); + break; + + case "vis": + telemetry = _normalizeVisitorTelemetry(telemetry); + break; + + default: + return; + } + this.store.push({ type, telemetry}); + + if (this.store.length > MAX_LOG_SIZE) { + this.store = this.store.slice(this.store.length - MAX_LOG_SIZE); + } + } + + public clear(): void { + this.store.length = 0; + } + + public clone(): TelemetryLog { + const cloned = new TelemetryLog(); + cloned.store = this.store.slice(0); + return cloned; + } + + public count(): number { + return this.store.length; + } + + public get(type: TelemetryType): Array { + return this.store + .filter((item) => item.type == type) + .map((item) => item.telemetry) as Array; + } + +} + +export function _normalizeConsoleTelemetry(telemetry: any) : ConsoleTelemetry { + telemetry.message = truncate(telemetry.message, MAX_MESSAGE_LENGTH); + return telemetry; +} + +export function _normalizeNavigationTelemetry(telemetry: any) : NavigationTelemetry { + telemetry.from = truncate(telemetry.from, MAX_URI_LENGTH); + telemetry.to = truncate(telemetry.to, MAX_URI_LENGTH); + return telemetry; +} + +export function _normalizeNetworkTelemetry(telemetry: any) : NetworkTelemetry { + telemetry.url = truncate(telemetry.url, MAX_URI_LENGTH); + return telemetry; +} + +export function _normalizeVisitorTelemetry(telemetry: any) : VisitorTelemetry { + type attributes = Record; + const currentAttributes: attributes = telemetry.element?.attributes || {}; + let normalizedAttributes: attributes = {}; + + const limitedAttributes = Object.entries(currentAttributes).slice(0, MAX_ATTRIBUTES); + + for (const [key, value] of limitedAttributes) { + normalizedAttributes[key] = truncate(value, MAX_ATTRIBUTE_VALUE); + } + + (telemetry.element || {}).attributes = normalizedAttributes; + + return telemetry; +} \ No newline at end of file diff --git a/packages/core/src/trackjs.ts b/packages/core/src/trackjs.ts index d75582d..87caba7 100644 --- a/packages/core/src/trackjs.ts +++ b/packages/core/src/trackjs.ts @@ -1,8 +1,18 @@ import { FetchTransport } from "./fetchTransport"; import { Client } from "./client"; -import { uuid } from "./utils"; - -import type { CapturePayload, ConsoleTelemetry, NavigationTelemetry, NetworkTelemetry, Options, TelemetryType, TrackOptions, VisitorTelemetry } from "./types"; +import { uuid, configureSerializer } from "./utils"; + +import type { + CapturePayload, + Options, + TrackOptions, + ConsoleTelemetry, + NavigationTelemetry, + NetworkTelemetry, + Telemetry, + TelemetryType, + VisitorTelemetry +} from "./types"; let config: Options | null = null; let client: Client | null = null; @@ -30,8 +40,17 @@ export function isInitialized(): boolean { /** * Initialize the TrackJS agent. + * @see {@link Options} for full options. + * + * @param options - Initial client options + * @param options.token - Your TrackJS account token from https://my.trackjs.com/ * - * @param options Initial client options. + * @example + * ``` + * TrackJS.initialize({ + * token: "abcde1234567890" + * }); + * ``` */ export function initialize(options: Partial & { token: string }): void { if (isInitialized()) { @@ -46,6 +65,11 @@ export function initialize(options: Partial & { token: string }): void ...options }; + configureSerializer({ + depth: 3, + handlers: config.serializer + }); + client = new Client(config); } @@ -54,7 +78,8 @@ export function initialize(options: Partial & { token: string }): void * Keys and values will be truncated to 500 characters. * * @param metadata - object with string values to be added as metadata. - * @example How to use addMetadata + * + * @example * ``` * TrackJS.addMetadata({ * 'customerStatus': 'paid', @@ -63,9 +88,9 @@ export function initialize(options: Partial & { token: string }): void * ``` */ export function addMetadata(metadata: Record): void { - if (!isInitialized()) { - throw new Error("TrackJS must be initialized"); - } + _checkInitialized(); + _checkRequired("Metadata", metadata); + return client!.addMetadata(metadata); } @@ -73,7 +98,8 @@ export function addMetadata(metadata: Record): void { * Remove keys from metadata. * * @param metadata - object with string properties to be removed from metadata. - * @example How to use removeMetadata + * + * @example * ``` * TrackJS.removeMetadata({ * 'customerStatus': null, @@ -82,26 +108,87 @@ export function addMetadata(metadata: Record): void { * ``` */ export function removeMetadata(metadata: Record): void { - if (!isInitialized()) { - throw new Error("TrackJS must be initialized"); - } + _checkInitialized(); + _checkRequired("Metadata", metadata); + return client!.removeMetadata(metadata); } -export function addTelemetry(type: TelemetryType, telemetry: ConsoleTelemetry|NavigationTelemetry|NetworkTelemetry|VisitorTelemetry): void { - throw new Error("not implemented"); +/** + * Adds an event to the Telemetry Log. The log can store events from Console, + * Network, Navigation, or Visitor, designated by the type key provided. TrackJS + * will store the last 30 events in the rolling log to be included with any error. + * + * @param type - The type of Telemetry to be provided + * @param telemetry - Any of the supported types. The telemetry object will be + * stored by reference, so you can update it after it has been added. This is + * particularly useful for network events. + * + * @example + * + * ``` + * TrackJS.addTelemetry("con", { + * timestamp: timestamp(), + * severity: "log", + * message: "My Log Message" + * }); + * ``` + * + * @example + * + * ``` + * const networkTelemetry = { + * type: "fetch", + * startedOn: timestamp(), + * method: "POST", + * url: "https://example.com/foo" + * }; + * TrackJS.addTelemetry("net", networkTelemetry);; + * + * // later when fetch completes + * networkTelemetry.completedOn = timestamp(); + * networkTelemetry.statusCode = 200; + * networkTelemetry.statusText = "OK"; + * ``` + */ +export function addTelemetry(type: "con", telemetry: ConsoleTelemetry): void; +export function addTelemetry(type: "nav", telemetry: NavigationTelemetry): void; +export function addTelemetry(type: "net", telemetry: NetworkTelemetry): void; +export function addTelemetry(type: "vis", telemetry: VisitorTelemetry): void; +export function addTelemetry(type: TelemetryType, telemetry: Telemetry): void { + _checkInitialized(); + _checkRequired("Type", type); + _checkRequired("Telemetry", telemetry); + + return client!.addTelemetry(type, telemetry); } export function addDependencies(...args: [dependencies: Record]): void { throw new Error("not implemented"); } -export function track(error: Error|object|string, options?: Partial): Promise { - if (!client) { - throw new Error("TrackJS must be initialized"); - } +/** + * Track and error or error-like object to the TrackJS error monitoring service. + * + * @param error - Error or error-like object. If a non-error is provided, it will + * attempt to serialize and generate a stack trace for the error. + * @param options.entry - (Optional) How this error was captured. Default: "direct" + * @param options.metadata - (Optional) Metadata key-values to be sent with this + * error in addition to the global metadata. Default: {} + * + * @returns Whether the error was sent or prevented by an event handler. + * + * @example + * ``` + * TrackJS.track(new Error("oops!"), { entry: "fetch", metadata: { "foo": "bar" }}); + * ``` + */ +export async function track(error: Error|object|string, options?: Partial): Promise { + _checkInitialized(); + _checkRequired("Error", error); - return client.track(error, options); + await client!.track(error, options); + return true; } export function usage(): void { @@ -112,9 +199,9 @@ export function onError(callback: (payload: CapturePayload) => boolean) : void { throw new Error("not implemented"); } -export function onTelemetry(callback: (type: TelemetryType, telemetry: ConsoleTelemetry|NavigationTelemetry|NetworkTelemetry|VisitorTelemetry) => boolean) : void { - throw new Error("not implemented"); -} +// export function onTelemetry(callback: (type: TelemetryType, telemetry: ConsoleTelemetry|NavigationTelemetry|NetworkTelemetry|VisitorTelemetry) => boolean) : void { +// throw new Error("not implemented"); +// } /** * Removes the TrackJS initialization and options. @@ -123,4 +210,16 @@ export function onTelemetry(callback: (type: TelemetryType, telemetry: ConsoleTe export function destroy(): void { config = null; client = null; +} + +function _checkInitialized() { + if (!client) { + throw new Error("TrackJS must be initialized"); + } +} + +function _checkRequired(name: string, val: any) { + if (!val) { + throw new Error(`${name} is required`) + } } \ No newline at end of file diff --git a/packages/core/src/types/payload.ts b/packages/core/src/types/payload.ts index 8c22c93..54e2f33 100644 --- a/packages/core/src/types/payload.ts +++ b/packages/core/src/types/payload.ts @@ -1,5 +1,10 @@ import type { ISO8601Date } from "./common"; -import type { ConsoleTelemetry, NavigationTelemetry, NetworkTelemetry, VisitorTelemetry } from "./telemetry"; +import type { + ConsoleTelemetry, + NavigationTelemetry, + NetworkTelemetry, + VisitorTelemetry +} from "./telemetry"; /** * Payload of an error sent to TrackJS. diff --git a/packages/core/src/types/telemetry.ts b/packages/core/src/types/telemetry.ts index a80704c..752921f 100644 --- a/packages/core/src/types/telemetry.ts +++ b/packages/core/src/types/telemetry.ts @@ -1,8 +1,10 @@ import type { HTTPMethods, ISO8601Date, SeverityLevel } from "./common"; -export type TelemetryType = "console" | "network" | "navigation" | "visitor"; +export type TelemetryType = "con" | "net" | "nav" | "vis"; -export interface ConsoleTelemetry { +export interface Telemetry {} + +export interface ConsoleTelemetry extends Telemetry { /** * Timestamp of the log event */ @@ -19,7 +21,7 @@ export interface ConsoleTelemetry { message: string; } -export interface NavigationTelemetry { +export interface NavigationTelemetry extends Telemetry { /** * Timestamp the navigation happened. */ @@ -41,7 +43,7 @@ export interface NavigationTelemetry { to: string; } -export interface NetworkTelemetry { +export interface NetworkTelemetry extends Telemetry { /** * Timestamp the request started */ @@ -78,7 +80,7 @@ export interface NetworkTelemetry { type: string; } -export interface VisitorTelemetry { +export interface VisitorTelemetry extends Telemetry { /** * Timestamp the event occurred */ diff --git a/packages/core/src/utils/serialize.ts b/packages/core/src/utils/serialize.ts index 95bfbd9..cc273b8 100644 --- a/packages/core/src/utils/serialize.ts +++ b/packages/core/src/utils/serialize.ts @@ -26,10 +26,30 @@ export interface SerializeOptions { handlers?: SerializeHandler[]; } +const defaultOptions: Required = { + depth: 3, + handlers: [] +} + +let globalOptions: Required = defaultOptions; + +export function configureSerializer(options: SerializeOptions): void { + globalOptions = { + ...defaultOptions, + ...options + }; +} + +/** + * Restores the serializer to its default configuration. Mainly used for testing + */ +export function restoreSerializer(): void { + globalOptions = defaultOptions; +} + export function serialize(thing: any, options?: SerializeOptions): string { const opts: Required = { - depth: 3, - handlers: [], + ...globalOptions, ...options }; diff --git a/packages/core/src/utils/truncate.ts b/packages/core/src/utils/truncate.ts index 041c909..3bd4df5 100644 --- a/packages/core/src/utils/truncate.ts +++ b/packages/core/src/utils/truncate.ts @@ -1,15 +1,33 @@ +import { isString } from "./isType"; + /** - * Truncate a string at a specified length and append ellipsis with a count - * of truncated characters. + * Truncate a string to exactly the specified length, appending ellipsis if + * characters were truncated beyond the specified length. * - * @param value String value to truncate - * @param length Maximum length of original string - * @example "this string was too...{12}". + * @param value - String value to truncate + * @param length - Maximum length of the returned string + * @returns Truncated string that may be appended with "…" + * + * @example + * ``` + * truncate("1234567890", 10); + * // returns "1234567890" + * + * truncate("1234567890", 8); + * // return "1234567…" + * ``` */ export function truncate(value: string, length: number): string { + // It's possible for the user the send us some unexpected type. Rather than + // let it blow up somewhere unexpected, or have the error report rejected, + // blow up explicitly. + if (!isString(value)) { + throw new Error("Value must be a string") + } + if (value.length <= length) { return value; } - var truncatedLength = value.length - length; - return `${value.substring(0, length)}…{${truncatedLength}}`; + + return `${value.substring(0, length - 1)}…`; } \ No newline at end of file diff --git a/packages/core/test/client.test.ts b/packages/core/test/client.test.ts index 77881d1..ab0d230 100644 --- a/packages/core/test/client.test.ts +++ b/packages/core/test/client.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeEach, vi } from "vitest"; import { Client } from "../src/client"; +import { timestamp } from "../src/utils"; import { MockTransport } from "./mocks/transport"; import type { Options } from "../src/types"; @@ -98,6 +99,82 @@ describe("_createPayload()", () => { { key: "local", value: "value2" } ]); }); + + test("includes telemetry", () => { + const client = new Client(defaultOptions); + client.addTelemetry("con", { + timestamp: timestamp(), + severity: "warn", + message: "test warning" + }); + client.addTelemetry("net", { + type: "fetch", + startedOn: timestamp(), + method: "PUT", + url: "https://example.com/thing" + }); + client.addTelemetry("nav", { + on: timestamp(), + type: "dunno", + from: "location1", + to: "location2" + }); + client.addTelemetry("vis", { + timestamp: timestamp(), + action: "click", + element: { + tag: "BUTTON", + attributes: { + class: "primary" + }, + value: { + length: 20, + pattern: "alpha" + } + } + }) + expect(client._createPayload(new Error("oops"), { entry: "direct", metadata: {} })).toMatchObject({ + console: [ + { + timestamp: expect.any(String), + severity: "warn", + message: "test warning" + } + ], + nav: [ + { + on: expect.any(String), + type: "dunno", + from: "location1", + to: "location2" + } + ], + network: [ + { + type: "fetch", + startedOn: expect.any(String), + method: "PUT", + url: "https://example.com/thing" + } + ], + visitor: [ + { + timestamp: expect.any(String), + action: "click", + element: { + tag: "BUTTON", + attributes: { + class: "primary" + }, + value: { + length: 20, + pattern: "alpha" + } + } + } + ] + }) + }); }); describe("_send()", () => { diff --git a/packages/core/test/metadata.test.ts b/packages/core/test/metadata.test.ts index 2f05f24..2fcf346 100644 --- a/packages/core/test/metadata.test.ts +++ b/packages/core/test/metadata.test.ts @@ -38,15 +38,15 @@ test("add() with non-string values", () => { test("add() truncates keys and values that exceed maximum length", () => { const m = new Metadata(); - const longKey = "k".repeat(500); - const longValue = "v".repeat(500); + const longKey = "k".repeat(1000); + const longValue = "v".repeat(1000); m.add({ [longKey]: longValue }); expect(m.get()).toEqual([ - { key: `${"k".repeat(490)}…{10}`, value: `${"v".repeat(490)}…{10}` } + { key: `${"k".repeat(499)}…`, value: `${"v".repeat(499)}…` } ]); }); diff --git a/packages/core/test/telemetryLog.test.ts b/packages/core/test/telemetryLog.test.ts new file mode 100644 index 0000000..952150a --- /dev/null +++ b/packages/core/test/telemetryLog.test.ts @@ -0,0 +1,322 @@ +import { test, expect } from "vitest"; +import { TelemetryLog, _normalizeConsoleTelemetry, _normalizeNavigationTelemetry, _normalizeNetworkTelemetry, _normalizeVisitorTelemetry } from "../src/telemetryLog"; +import { timestamp } from "../src/utils"; +import type { NetworkTelemetry } from "../src/types"; +import { HTTPMethods } from "../dist/types"; + +test("add() adds telemetry to log", () => { + const tl = new TelemetryLog(); + tl.add("con", { + timestamp: timestamp(), + severity: "log", + message: "test message" + }); + tl.add("nav", { + on: timestamp(), + type: "replaceState", + from: "location1", + to: "location2" + }); + tl.add("net", { + type: "fetch", + startedOn: timestamp(), + method: "GET", + url: "https://example.com/" + }); + tl.add("vis", { + timestamp: timestamp(), + action: "click", + element: { + tag: "DIV", + attributes: { + "class": "btn" + }, + value: { + length: 10, + pattern: "numeric" + } + } + }); + expect(tl.get("con")).toStrictEqual([ + expect.objectContaining({ + timestamp: expect.any(String), + severity: "log", + message: "test message" + }) + ]); + expect(tl.get("nav")).toStrictEqual([ + expect.objectContaining({ + on: expect.any(String), + type: "replaceState", + from: "location1", + to: "location2" + }) + ]); + expect(tl.get("net")).toStrictEqual([ + expect.objectContaining({ + type: "fetch", + startedOn: expect.any(String), + method: "GET", + url: "https://example.com/" + }) + ]); + expect(tl.get("vis")).toStrictEqual([ + expect.objectContaining({ + timestamp: expect.any(String), + action: "click", + element: { + tag: "DIV", + attributes: { + "class": "btn" + }, + value: { + length: 10, + pattern: "numeric" + } + } + }) + ]); +}); + +test("add() rolls oldest items from overflowing log", () => { + const tl = new TelemetryLog(); + for (let i = 0; i < 40; i++) { + tl.add("con", { + timestamp: timestamp(), + severity: "log", + message: `test-message-${i}` + }); + } + expect(tl.count()).toBe(30); + expect(tl.get("con")).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "test-message-10" + }), + expect.objectContaining({ + message: "test-message-39" + }) + ]) + ) +}); + +test("add() allows logged objects to be updated", () => { + const tl = new TelemetryLog(); + const nt: NetworkTelemetry = { + startedOn: timestamp(), + type: "fetch", + method: "GET", + url: "https://example.com/foo" + }; + tl.add("net", nt); + + nt.completedOn = timestamp(); + nt.statusCode = 404; + nt.statusText = "NOT FOUND"; + expect(tl.get("net")).toStrictEqual([ + { + startedOn: expect.any(String), + type: "fetch", + method: "GET", + url: "https://example.com/foo", + completedOn: expect.any(String), + statusCode: 404, + statusText: "NOT FOUND" + } + ]) +}); + +test("clear() empties the log", () => { + const tl = new TelemetryLog(); + tl.add("con", { + timestamp: timestamp(), + severity: "log", + message: "test-message" + }); + expect(tl.count()).toBe(1); + tl.clear(); + expect(tl.count()).toBe(0); + expect(tl.get("con")).toStrictEqual([]); +}); + +test("clone() creates a copy of the log that can be added separately", () => { + const tl1 = new TelemetryLog(); + tl1.add("con", { + timestamp: timestamp(), + severity: "log", + message: "test-message-1" + }); + + const tl2 = tl1.clone(); + tl2.add("con", { + timestamp: timestamp(), + severity: "log", + message: "test-message-2" + }); + + expect(tl1.count()).toBe(1); + expect(tl1.get("con")).toStrictEqual([ + { + timestamp: expect.any(String), + severity: "log", + message: "test-message-1" + } + ]); + + expect(tl2.count()).toBe(2); + expect(tl2.get("con")).toStrictEqual([ + { + timestamp: expect.any(String), + severity: "log", + message: "test-message-1" + }, + { + timestamp: expect.any(String), + severity: "log", + message: "test-message-2" + } + ]); + +}) + +test("_normalizeConsoleTelemetry() with normal object", () => { + const normalized = _normalizeConsoleTelemetry({ + timestamp: timestamp(), + severity: "log" as const, + message: "This is a normal console message" + }); + + expect(normalized).toStrictEqual({ + timestamp: expect.any(String), + severity: "log", + message: "This is a normal console message" + }); +}); + +test("_normalizeConsoleTelemetry() truncates long message", () => { + const normalized = _normalizeConsoleTelemetry({ + timestamp: timestamp(), + severity: "error" as const, + message: "x".repeat(15000) + }); + + expect(normalized.message.length).toBe(10000); +}); + +test("_normalizeNavigationTelemetry() with normal object", () => { + const normalized = _normalizeNavigationTelemetry({ + on: timestamp(), + type: "pushState", + from: "https://example.com/page1", + to: "https://example.com/page2" + }); + + expect(normalized).toStrictEqual({ + on: expect.any(String), + type: "pushState", + from: "https://example.com/page1", + to: "https://example.com/page2" + }); +}); + +test("_normalizeNavigationTelemetry() truncates long URLs", () => { + const normalized = _normalizeNavigationTelemetry({ + on: timestamp(), + type: "replaceState", + from: "https://example.com/" + "a".repeat(2000), + to: "https://example.com/" + "b".repeat(2000) + }); + + expect(normalized.from.length).toBe(1000); + expect(normalized.to.length).toBe(1000); +}); + +test("_normalizeNetworkTelemetry() with normal object", () => { + const normalized = _normalizeNetworkTelemetry({ + type: "xhr", + startedOn: timestamp(), + method: "POST", + url: "https://api.example.com/users" + }); + + expect(normalized).toStrictEqual({ + type: "xhr", + startedOn: expect.any(String), + method: "POST", + url: "https://api.example.com/users" + }); +}); + +test("_normalizeNetworkTelemetry() truncates long URL", () => { + const normalized = _normalizeNetworkTelemetry({ + type: "fetch", + startedOn: timestamp(), + method: "GET", + url: "https://api.example.com/" + "path".repeat(500) + }); + + expect(normalized.url.length).toBe(1000); +}); + +test("_normalizeVisitorTelemetry() with normal object", () => { + const normalized = _normalizeVisitorTelemetry({ + timestamp: timestamp(), + action: "click", + element: { + tag: "BUTTON", + attributes: { + "class": "btn btn-primary", + "id": "submit-button", + "data-test": "submit" + }, + value: { + length: 5, + pattern: "numeric" + } + } + }); + + expect(normalized).toStrictEqual({ + timestamp: expect.any(String), + action: "click", + element: { + tag: "BUTTON", + attributes: { + "class": "btn btn-primary", + "id": "submit-button", + "data-test": "submit" + }, + value: { + length: 5, + pattern: "numeric" + } + } + }); +}); + +test("_normalizeVisitorTelemetry() truncates attributes", () => { + const attributes: { [key: string]: string } = {}; + for (let i = 0; i < 30; i++) { + attributes[`attr-${i}`] = "value".repeat(200); + } + + const normalized = _normalizeVisitorTelemetry({ + timestamp: timestamp(), + action: "change", + element: { + tag: "INPUT", + attributes: attributes, + value: { + length: 100, + pattern: "characters" + } + } + }); + + const normalizedAttributeKeys = Object.keys(normalized.element.attributes); + expect(normalizedAttributeKeys.length).toBe(20); + + for (const value of Object.values(normalized.element.attributes)) { + expect(value.length).toEqual(500); + } +}) \ No newline at end of file diff --git a/packages/core/test/utils/serialize.test.ts b/packages/core/test/utils/serialize.test.ts index 8f32b87..721b0f5 100644 --- a/packages/core/test/utils/serialize.test.ts +++ b/packages/core/test/utils/serialize.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from 'vitest'; -import { serialize } from '../../src/utils/serialize'; +import { describe, expect, test, afterEach } from 'vitest'; +import { serialize, configureSerializer, restoreSerializer } from '../../src/utils/serialize'; + +afterEach(() => { + restoreSerializer(); +}) test.each([ ["", '""'], @@ -208,6 +212,23 @@ describe('serializing with custom handlers', () => { serialize: (thing: any) => "handler1" }; expect(serialize(42, { handlers: [handler1] })).toBe("42"); + }); + + test('uses configured depth', () => { + configureSerializer({ depth: 1 }); + expect(serialize({"first-level":{"second-level":"test"}})).toBe('{"first-level":{Object}}'); + }); + + test('uses configured handlers', () => { + configureSerializer({ + handlers: [ + { + test: (thing: any) => true, + serialize: (thing: any) => "handler1" + } + ] + }); + expect(serialize("anything at all")).toBe('handler1'); }) }); diff --git a/packages/core/test/utils/truncate.test.ts b/packages/core/test/utils/truncate.test.ts index 84bc2c0..6730f7c 100644 --- a/packages/core/test/utils/truncate.test.ts +++ b/packages/core/test/utils/truncate.test.ts @@ -1,10 +1,18 @@ import { expect, test } from "vitest"; -import { truncate } from "../../src/utils/truncate"; +import { truncate } from "../../src/utils"; -test("returns strings shorter then length unchanged", () => { - expect(truncate("a normal string", 15)).toBe("a normal string"); +test("throws when non-string is provided", () => { + expect(() => truncate(false as unknown as string, 1)).toThrow("Value must be a string"); + expect(() => truncate({} as unknown as string, 1)).toThrow("Value must be a string"); + expect(() => truncate(12345 as unknown as string, 1)).toThrow("Value must be a string"); }); -test("appends ellipsis and length to long string", () => { - expect(truncate("a too long string", 5)).toBe("a too…{12}"); +test("returns string shorter than length", () => { + expect(truncate("1234567890", 10)).toBe("1234567890"); + expect(truncate("a".repeat(1000), 1000)).toBe("a".repeat(1000)); }); + +test("returns truncated string longer than length", () => { + expect(truncate("1234567890", 8)).toBe("1234567…"); + expect(truncate("a".repeat(1000), 100)).toBe(`${"a".repeat(99)}…`); +}); \ No newline at end of file