diff --git a/example/browser/index.js b/example/browser/index.js index 73a66904..2426cc45 100644 --- a/example/browser/index.js +++ b/example/browser/index.js @@ -13,6 +13,7 @@ await Exceptionless.startup((c) => { c.updateSettingsWhenIdleInterval = 15000; c.usePersistedQueueStorage = true; c.setUserIdentity("12345678", "Blake"); + c.useSessions(); // set some default data c.defaultData["SampleUser"] = { diff --git a/packages/angularjs/package.json b/packages/angularjs/package.json index dddd7bc1..7b571db2 100644 --- a/packages/angularjs/package.json +++ b/packages/angularjs/package.json @@ -33,8 +33,8 @@ "./package.json": "./package.json" }, "scripts": { - "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2015 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2015 --format=esm --outfile=dist/index.bundle.min.js", - "watch": "tsc -p ../core/tsconfig.json -w --preserveWatchOutput & tsc -p tsconfig.json -w --preserveWatchOutput & esbuild src/index.ts --bundle --sourcemap --target=es2015 --format=esm --watch --outfile=dist/index.bundle.js" + "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.min.js", + "watch": "tsc -p ../core/tsconfig.json -w --preserveWatchOutput & tsc -p tsconfig.json -w --preserveWatchOutput & esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --watch --outfile=dist/index.bundle.js" }, "sideEffects": false, "publishConfig": { diff --git a/packages/browser/package.json b/packages/browser/package.json index e1a1621a..0838eced 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -45,7 +45,7 @@ "testEnvironment": "jsdom" }, "scripts": { - "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2019 --format=esm --outfile=dist/index.bundle.min.js", + "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.min.js", "watch": "tsc -p ../core/tsconfig.json -w --preserveWatchOutput & tsc -p tsconfig.json -w --preserveWatchOutput & esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --watch --outfile=dist/index.bundle.js", "test": "jest" }, diff --git a/packages/browser/src/plugins/BrowserLifeCyclePlugin.ts b/packages/browser/src/plugins/BrowserLifeCyclePlugin.ts index 7c6188a5..c1f98065 100644 --- a/packages/browser/src/plugins/BrowserLifeCyclePlugin.ts +++ b/packages/browser/src/plugins/BrowserLifeCyclePlugin.ts @@ -17,12 +17,19 @@ export class BrowserLifeCyclePlugin implements IEventPlugin { this._client = context.client; - globalThis.addEventListener("beforeunload", () => void this._client?.suspend()); + globalThis.addEventListener("beforeunload", () => { + if (this._client?.config.sessionsEnabled) { + void this._client?.submitSessionEnd(); + } + + void this._client?.suspend(); + }); + document.addEventListener("visibilitychange", () => { if (document.visibilityState === 'visible') { - void this._client?.startup() + void this._client?.startup(); } else { - void this._client?.suspend() + void this._client?.suspend(); } }); diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json index 90cfe5ba..8e36fb94 100644 --- a/packages/browser/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "lib": [ "DOM", - "ES2020" + "ES2021" ], "outDir": "dist", "rootDir": "src", diff --git a/packages/core/package.json b/packages/core/package.json index ecdbfcf3..ed777153 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,7 @@ "testEnvironment": "jsdom" }, "scripts": { - "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2019 --format=esm --outfile=dist/index.bundle.min.js", + "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.min.js", "watch": "tsc -p tsconfig.json -w --preserveWatchOutput & esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --watch --outfile=dist/index.bundle.js", "test": "jest" }, diff --git a/packages/core/src/EventBuilder.ts b/packages/core/src/EventBuilder.ts index 4e43a1a3..f81316ba 100644 --- a/packages/core/src/EventBuilder.ts +++ b/packages/core/src/EventBuilder.ts @@ -1,5 +1,5 @@ import { ExceptionlessClient } from "./ExceptionlessClient.js"; -import { Event, KnownEventDataKeys } from "./models/Event.js"; +import { Event, EventType, KnownEventDataKeys } from "./models/Event.js"; import { ManualStackingInfo } from "./models/data/ManualStackingInfo.js"; import { UserInfo } from "./models/data/UserInfo.js"; import { EventContext } from "./models/EventContext.js"; @@ -19,7 +19,7 @@ export class EventBuilder { this.context = context || new EventContext(); } - public setType(type: string): EventBuilder { + public setType(type: EventType): EventBuilder { if (type) { this.target.type = type; } diff --git a/packages/core/src/ExceptionlessClient.ts b/packages/core/src/ExceptionlessClient.ts index 350ce1bf..d652c635 100644 --- a/packages/core/src/ExceptionlessClient.ts +++ b/packages/core/src/ExceptionlessClient.ts @@ -46,6 +46,10 @@ export class ExceptionlessClient { // TODO: Can we schedule this as part of startup? await queue.process(); } + + if (this.config.sessionsEnabled) { + await this.submitSessionStart(); + } } /** Submit events, pause any timers and go into low power mode. */ @@ -175,27 +179,21 @@ export class ExceptionlessClient { return this.createSessionStart().submit(); } - public async submitSessionEnd(sessionIdOrUserId: string): Promise { - if (sessionIdOrUserId && this.config.enabled && this.config.isValid) { - this.config.services.log.info( - `Submitting session end: ${sessionIdOrUserId}`, - ); - await this.config.services.submissionClient.submitHeartbeat( - sessionIdOrUserId, - true, - ); + public async submitSessionEnd(sessionIdOrUserId?: string): Promise { + const { currentSessionIdentifier, enabled, isValid, services } = this.config; + const sessionId = sessionIdOrUserId || currentSessionIdentifier; + if (sessionId && enabled && isValid) { + services.log.info(`Submitting session end: ${sessionId}`); + await services.submissionClient.submitHeartbeat(sessionId, true); } } - public async submitSessionHeartbeat(sessionIdOrUserId: string): Promise { - if (sessionIdOrUserId && this.config.enabled && this.config.isValid) { - this.config.services.log.info( - `Submitting session heartbeat: ${sessionIdOrUserId}`, - ); - await this.config.services.submissionClient.submitHeartbeat( - sessionIdOrUserId, - false, - ); + public async submitSessionHeartbeat(sessionIdOrUserId?: string): Promise { + const { currentSessionIdentifier, enabled, isValid, services } = this.config; + const sessionId = sessionIdOrUserId || currentSessionIdentifier; + if (sessionId && enabled && isValid) { + services.log.info(`Submitting session heartbeat: ${sessionId}`); + await services.submissionClient.submitHeartbeat(sessionId, false); } } diff --git a/packages/core/src/configuration/Configuration.ts b/packages/core/src/configuration/Configuration.ts index 6a934865..dd02ce51 100644 --- a/packages/core/src/configuration/Configuration.ts +++ b/packages/core/src/configuration/Configuration.ts @@ -5,6 +5,7 @@ import { ConsoleLog } from "../logging/ConsoleLog.js"; import { NullLog } from "../logging/NullLog.js"; import { UserInfo } from "../models/data/UserInfo.js"; import { HeartbeatPlugin } from "../plugins/default/HeartbeatPlugin.js"; +import { SessionIdManagementPlugin } from "../plugins/default/SessionIdManagementPlugin.js"; import { EventPluginContext } from "../plugins/EventPluginContext.js"; import { EventPluginManager } from "../plugins/EventPluginManager.js"; import { IEventPlugin } from "../plugins/IEventPlugin.js"; @@ -428,32 +429,24 @@ export class Configuration { } /** - * Set the default user identity for all events. If the heartbeat interval is - * greater than 0 (default: 30000ms), heartbeats will be sent after the first - * event submission. + * Set the default user identity for all events. */ - public setUserIdentity(userInfo: UserInfo, heartbeatInterval?: number): void; - public setUserIdentity(identity: string, heartbeatInterval?: number): void; - public setUserIdentity(identity: string, name: string, heartbeatInterval?: number): void; - public setUserIdentity(userInfoOrIdentity: UserInfo | string, nameOrHeartbeatInterval?: string | number, heartbeatInterval: number = 30000): void { - const name: string | undefined = typeof nameOrHeartbeatInterval === "string" ? nameOrHeartbeatInterval : undefined; + public setUserIdentity(userInfo: UserInfo): void; + public setUserIdentity(identity: string): void; + public setUserIdentity(identity: string, name: string): void; + public setUserIdentity(userInfoOrIdentity: UserInfo | string, name?: string): void { const userInfo: UserInfo = typeof userInfoOrIdentity !== "string" ? userInfoOrIdentity : { identity: userInfoOrIdentity, name }; - const interval: number = typeof nameOrHeartbeatInterval === "number" ? nameOrHeartbeatInterval : heartbeatInterval; - const plugin = new HeartbeatPlugin(interval); - const shouldRemove: boolean = !userInfo || (!userInfo.identity && !userInfo.name); if (shouldRemove) { - this.removePlugin(plugin) delete this.defaultData[KnownEventDataKeys.UserInfo]; } else { - this.addPlugin(plugin) this.defaultData[KnownEventDataKeys.UserInfo] = userInfo; } - this.services.log.info(`user identity: ${shouldRemove ? "null" : userInfo.identity} (heartbeat interval: ${interval}ms)`); + this.services.log.info(`user identity: ${shouldRemove ? "null" : userInfo.identity}`); } /** @@ -477,7 +470,39 @@ export class Configuration { * This setting only works in environments that supports persisted storage. * There is also a performance penalty of extra IO/serialization. */ - public usePersistedQueueStorage = false; + public usePersistedQueueStorage: boolean = false; + + /** + * Gets or sets a value indicating whether to automatically send session start, + * session heartbeats and session end events. + */ + public sessionsEnabled = false; + + /** + * Internal property used to track the current session identifier. + */ + public currentSessionIdentifier: string | null = null; + + /** + * + * @param sendHeartbeats Controls whether heartbeat events are sent on an interval. + * @param heartbeatInterval The interval at which heartbeats are sent after the last sent event. The default is 1 minutes. + * @param useSessionIdManagement Allows you to manually control the session id. This is only recommended for single user desktop environments. + */ + public useSessions(sendHeartbeats: boolean = true, heartbeatInterval: number = 60000, useSessionIdManagement: boolean = false) { + this.sessionsEnabled = true; + + if (useSessionIdManagement) { + this.addPlugin(new SessionIdManagementPlugin()); + } + + const plugin = new HeartbeatPlugin(heartbeatInterval); + if (sendHeartbeats) { + this.addPlugin(plugin); + } else { + this.removePlugin(plugin); + } + } private originalSettings?: Record; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2e88cf61..7773c1b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,7 @@ export type { ILog } from "./logging/ILog.js"; export { ConsoleLog } from "./logging/ConsoleLog.js"; export { NullLog } from "./logging/NullLog.js"; -export type { Event, IEventData } from "./models/Event.js"; +export type { Event, EventType, IEventData } from "./models/Event.js"; export { KnownEventDataKeys } from "./models/Event.js"; export type { EnvironmentInfo } from "./models/data/EnvironmentInfo.js"; export type { ManualStackingInfo } from "./models/data/ManualStackingInfo.js"; @@ -30,6 +30,7 @@ export { DuplicateCheckerPlugin } from "./plugins/default/DuplicateCheckerPlugin export { EventExclusionPlugin } from "./plugins/default/EventExclusionPlugin.js"; export { HeartbeatPlugin } from "./plugins/default/HeartbeatPlugin.js"; export { ReferenceIdPlugin } from "./plugins/default/ReferenceIdPlugin.js"; +export { SessionIdManagementPlugin } from "./plugins/default/SessionIdManagementPlugin.js"; export { IgnoredErrorProperties, SimpleErrorPlugin } from "./plugins/default/SimpleErrorPlugin.js" export { SubmissionMethodPlugin } from "./plugins/default/SubmissionMethodPlugin.js"; export { EventContext } from "./models/EventContext.js"; diff --git a/packages/core/src/models/Event.ts b/packages/core/src/models/Event.ts index a6ee2a1a..408f513d 100644 --- a/packages/core/src/models/Event.ts +++ b/packages/core/src/models/Event.ts @@ -5,9 +5,11 @@ import { UserInfo } from "./data/UserInfo.js"; import { UserDescription } from "./data/UserDescription.js"; import { ManualStackingInfo } from "./data/ManualStackingInfo.js"; +export type EventType = "error" | "usage" | "log" | "404" | "session" | string; + export interface Event { /** The event type (ie. error, log message, feature usage). */ - type?: string; + type?: EventType; /** The event source (ie. machine name, log name, feature name). */ source?: string; /** The date that the event occurred on. */ diff --git a/packages/core/src/plugins/default/HeartbeatPlugin.ts b/packages/core/src/plugins/default/HeartbeatPlugin.ts index 1a786a3e..46a7c279 100644 --- a/packages/core/src/plugins/default/HeartbeatPlugin.ts +++ b/packages/core/src/plugins/default/HeartbeatPlugin.ts @@ -9,7 +9,7 @@ export class HeartbeatPlugin implements IEventPlugin { private _interval: number; private _intervalId: ReturnType | undefined; - constructor(heartbeatInterval: number = 30000) { + constructor(heartbeatInterval: number = 60000) { this._interval = heartbeatInterval >= 30000 ? heartbeatInterval : 60000; } @@ -34,11 +34,20 @@ export class HeartbeatPlugin implements IEventPlugin { clearInterval(this._intervalId); this._intervalId = undefined; - const user = context.event.data?.[KnownEventDataKeys.UserInfo]; - if (user?.identity) { + const { config } = context.client; + if (!config.currentSessionIdentifier) { + const user = context.event.data?.[KnownEventDataKeys.UserInfo]; + if (!user?.identity) { + return Promise.resolve(); + } + + config.currentSessionIdentifier = user.identity; + } + + if (config.currentSessionIdentifier) { this._intervalId = setInterval( - () => void context.client.submitSessionHeartbeat(user.identity), - this._interval, + () => void context.client.submitSessionHeartbeat(config.currentSessionIdentifier), + this._interval ); } diff --git a/packages/core/src/plugins/default/ReferenceIdPlugin.ts b/packages/core/src/plugins/default/ReferenceIdPlugin.ts index bffc9273..d8c2f14f 100644 --- a/packages/core/src/plugins/default/ReferenceIdPlugin.ts +++ b/packages/core/src/plugins/default/ReferenceIdPlugin.ts @@ -9,7 +9,7 @@ export class ReferenceIdPlugin implements IEventPlugin { public run(context: EventPluginContext): Promise { if (!context.event.reference_id && context.event.type === "error") { // PERF: Optimize identifier creation. - context.event.reference_id = guid().replace("-", "").substring(0, 10); + context.event.reference_id = guid().replaceAll("-", "").substring(0, 10); } return Promise.resolve(); diff --git a/packages/core/src/plugins/default/SessionIdManagementPlugin.ts b/packages/core/src/plugins/default/SessionIdManagementPlugin.ts new file mode 100644 index 00000000..09cde73f --- /dev/null +++ b/packages/core/src/plugins/default/SessionIdManagementPlugin.ts @@ -0,0 +1,29 @@ +import { guid } from "../../Utils.js"; +import { EventPluginContext } from "../EventPluginContext.js"; +import { IEventPlugin } from "../IEventPlugin.js"; + +export class SessionIdManagementPlugin implements IEventPlugin { + public priority = 25; + public name = "SessionIdManagementPlugin"; + + public run(context: EventPluginContext): Promise { + const ev = context.event; + const isSessionStart: boolean = ev.type === "session"; + const { config } = context.client; + if (isSessionStart || !config.currentSessionIdentifier) { + config.currentSessionIdentifier = guid().replaceAll("-", ""); + } + + if (isSessionStart) { + ev.reference_id = config.currentSessionIdentifier; + } else { + if (!ev.data) { + ev.data = {}; + } + + ev.data["@ref:session"] = config.currentSessionIdentifier; + } + + return Promise.resolve(); + } +} diff --git a/packages/core/test/plugins/default/EventExclusionPlugin.test.ts b/packages/core/test/plugins/default/EventExclusionPlugin.test.ts index b3b681b9..5feb59b3 100644 --- a/packages/core/test/plugins/default/EventExclusionPlugin.test.ts +++ b/packages/core/test/plugins/default/EventExclusionPlugin.test.ts @@ -2,7 +2,7 @@ import { describe, test } from "@jest/globals"; import { expect } from "expect"; import { ExceptionlessClient } from "../../../src/ExceptionlessClient.js"; -import { Event, KnownEventDataKeys } from "../../../src/models/Event.js"; +import { Event, EventType, KnownEventDataKeys } from "../../../src/models/Event.js"; import { InnerErrorInfo } from "../../../src/models/data/ErrorInfo.js"; import { EventExclusionPlugin } from "../../../src/plugins/default/EventExclusionPlugin.js"; import { EventPluginContext } from "../../../src/plugins/EventPluginContext.js"; @@ -142,7 +142,7 @@ describe("EventExclusionPlugin", () => { }); describe("should exclude source type", () => { - const run = async (type: string | null | undefined, source: string | undefined, settingKey: string | null | undefined, settingValue: string | null | undefined): Promise => { + const run = async (type: EventType | null | undefined, source: string | undefined, settingKey: string | null | undefined, settingValue: string | null | undefined): Promise => { const client = new ExceptionlessClient(); if (typeof settingKey === "string") { diff --git a/packages/core/test/submission/TestSubmissionClient.test.ts b/packages/core/test/submission/TestSubmissionClient.test.ts index 49f3c0aa..af8283a4 100644 --- a/packages/core/test/submission/TestSubmissionClient.test.ts +++ b/packages/core/test/submission/TestSubmissionClient.test.ts @@ -20,7 +20,7 @@ describe("TestSubmissionClient", () => { const apiFetchMock = jest.fn<(url: string, options: FetchOptions) => Promise>>() .mockReturnValueOnce(Promise.resolve(new Response(202, "", NaN, NaN, undefined))); - const events = [{ type: "log", message: "From js client", reference_id: "123454321" }]; + const events: Event[] = [{ type: "log", message: "From js client", reference_id: "123454321" }]; const client = new TestSubmissionClient(config, apiFetchMock); await client.submitEvents(events); expect(apiFetchMock).toHaveBeenCalledTimes(1); diff --git a/packages/node/src/plugins/NodeLifeCyclePlugin.ts b/packages/node/src/plugins/NodeLifeCyclePlugin.ts index 0ed02505..298293cc 100644 --- a/packages/node/src/plugins/NodeLifeCyclePlugin.ts +++ b/packages/node/src/plugins/NodeLifeCyclePlugin.ts @@ -23,6 +23,10 @@ export class NodeLifeCyclePlugin implements IEventPlugin { void this._client?.submitLog("beforeExit", message, "Error"); } + if (this._client?.config.sessionsEnabled) { + void this._client?.submitSessionEnd(); + } + void this._client?.suspend(); // Application will now exit. }); diff --git a/packages/react/package.json b/packages/react/package.json index a142dd6b..dc54e391 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -33,7 +33,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2019 --format=esm --outfile=dist/index.bundle.min.js", + "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.min.js", "watch": "tsc -p ../core/tsconfig.json -w --preserveWatchOutput & tsc -p tsconfig.json -w --preserveWatchOutput & esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --watch --outfile=dist/index.bundle.js" }, "sideEffects": false, diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 2f16891f..c357c742 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "lib": ["DOM", "ES2020"], + "lib": [ + "DOM", + "ES2021" + ], "outDir": "dist", "rootDir": "src", "jsx": "react", diff --git a/packages/vue/package.json b/packages/vue/package.json index 4b72d763..b38258e7 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -33,7 +33,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2019 --format=esm --outfile=dist/index.bundle.min.js", + "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.js && esbuild src/index.ts --bundle --minify --sourcemap --target=es2017 --format=esm --outfile=dist/index.bundle.min.js", "watch": "tsc -p tsconfig.json -w --preserveWatchOutput & && esbuild src/index.ts --bundle --sourcemap --target=es2017 --format=esm --watch --outfile=dist/index.bundle.js &" }, "sideEffects": false, diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index ca546457..db5dd490 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "lib": ["DOM", "ES2020"], + "lib": [ + "DOM", + "ES2021" + ], "outDir": "dist", "rootDir": "src" }, diff --git a/tsconfig.json b/tsconfig.json index dec5f010..c41ce413 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "forceConsistentCasingInFileNames": true, "isolatedModules": true, "lib": [ - "ES2020", + "ES2021", "DOM" ], "module": "ESNext",