diff --git a/packages/cypress/package.json b/packages/cypress/package.json index 7f1b4855d..87b725bb5 100644 --- a/packages/cypress/package.json +++ b/packages/cypress/package.json @@ -52,6 +52,7 @@ "is-uuid": "^1.0.2", "jsonata": "^1.8.6", "launchdarkly-node-client-sdk": "^3.2.1", + "mixpanel": "^0.18.0", "node-fetch": "^2.6.7", "p-map": "^4.0.0", "semver": "^7.5.2", diff --git a/packages/jest/package.json b/packages/jest/package.json index 3bed147c3..4ceaf9ec3 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -55,6 +55,7 @@ "jest-runtime": "^27.5.1", "jsonata": "^1.8.6", "launchdarkly-node-client-sdk": "^3.2.1", + "mixpanel": "^0.18.0", "node-fetch": "^2.6.7", "p-map": "^4.0.0", "sha-1": "^1.0.0", diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 125fc82c3..93b39cb54 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -50,6 +50,7 @@ "is-uuid": "^1.0.2", "jsonata": "^1.8.6", "launchdarkly-node-client-sdk": "^3.2.1", + "mixpanel": "^0.18.0", "node-fetch": "^2.6.7", "p-map": "^4.0.0", "sha-1": "^1.0.0", diff --git a/packages/playwright/src/reporter.ts b/packages/playwright/src/reporter.ts index 87dcf9d50..6baa878b5 100644 --- a/packages/playwright/src/reporter.ts +++ b/packages/playwright/src/reporter.ts @@ -5,26 +5,28 @@ import type { TestError, TestResult, } from "@playwright/test/reporter"; +import { initLogger, logger } from "@replay-cli/shared/logger"; +import { mixpanelAPI } from "@replay-cli/shared/mixpanel/mixpanelAPI"; +import { getRuntimePath } from "@replay-cli/shared/runtime/getRuntimePath"; +import { emphasize, highlight, link } from "@replay-cli/shared/theme"; +import { setUserAgent } from "@replay-cli/shared/userAgent"; import { - getMetadataFilePath as getMetadataFilePathBase, - removeAnsiCodes, ReplayReporter, ReplayReporterConfig, TestMetadataV2, + getAccessToken, + getMetadataFilePath as getMetadataFilePathBase, + removeAnsiCodes, } from "@replayio/test-utils"; -import { readFileSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import path from "path"; import { WebSocketServer } from "ws"; - -type UserActionEvent = TestMetadataV2.UserActionEvent; - -import { getRuntimePath } from "@replay-cli/shared/runtime/getRuntimePath"; +import { name as packageName, version as packageVersion } from "../package.json"; import { FixtureStepStart, ParsedErrorFrame, TestExecutionIdData } from "./fixture"; import { StackFrame } from "./playwrightTypes"; import { getServerPort, startServer } from "./server"; -import { initLogger, logger } from "@replay-cli/shared/logger"; -import pkgJson from "../package.json"; -import { setUserAgent } from "@replay-cli/shared/userAgent"; + +type UserActionEvent = TestMetadataV2.UserActionEvent; export function getMetadataFilePath(workerIndex = 0) { return getMetadataFilePathBase("PLAYWRIGHT", workerIndex); @@ -68,7 +70,7 @@ interface FixtureStep extends FixtureStepStart { error?: ParsedErrorFrame | undefined; } -class ReplayPlaywrightReporter implements Reporter { +export default class ReplayPlaywrightReporter implements Reporter { reporter: ReplayReporter; captureTestFile: boolean; config: ReplayPlaywrightConfig; @@ -80,9 +82,18 @@ class ReplayPlaywrightReporter implements Reporter { private _foundReplayBrowser = false; constructor(config: ReplayPlaywrightConfig) { - setUserAgent(`${pkgJson.name}/${pkgJson.version}`); - initLogger(pkgJson.name, pkgJson.version); + setUserAgent(`${packageName}/${packageVersion}`); + + initLogger(packageName, packageVersion); + mixpanelAPI.initialize({ + accessToken: getAccessToken(config), + packageName, + packageVersion, + }); + if (!config || typeof config !== "object") { + mixpanelAPI.trackEvent("playwright.error.invalid-reporter-config", { config }); + throw new Error( `Expected an object for @replayio/playwright/reporter configuration but received: ${config}` ); @@ -93,9 +104,9 @@ class ReplayPlaywrightReporter implements Reporter { { name: "playwright", version: undefined, - plugin: pkgJson.version, + plugin: packageVersion, }, - "2.2.0", + "2.2.0", // Schema version { ...this.config, metadataKey: "PLAYWRIGHT_REPLAY_METADATA" } ); this.captureTestFile = @@ -122,8 +133,10 @@ class ReplayPlaywrightReporter implements Reporter { s.error = step.error; } }, - onError: (_test, error) => { - this.reporter?.addError(error); + onError: (test, error) => { + this.reporter?.addError(error, { + ...test, + }); }, }); } @@ -328,12 +341,24 @@ class ReplayPlaywrightReporter implements Reporter { try { await this.reporter.onEnd(); if (!this._foundReplayBrowser) { - console.warn( - "[replay.io]: None of the configured projects ran using Replay Chromium. Please recheck your Playwright config and make sure that Replay Chromium is installed. You can install it using `npx replayio install`" + mixpanelAPI.trackEvent("playwright.warning.reporter-used-without-browser"); + + const output: string[] = []; + output.push(emphasize("None of the configured projects ran using Replay Chromium.")); + if (!existsSync(getRuntimePath())) { + output.push(""); + output.push(`Install Replay Chromium by running ${highlight("npx replayio install")}`); + } + output.push(""); + output.push( + `Learn more at ${link( + "https://docs.replay.io/reference/test-runners/playwright/overview" + )}` ); + output.map(line => console.warn(`[replay.io]: ${line}`)); } } finally { - await logger.close().catch(() => {}); + await Promise.all([mixpanelAPI.close().catch(noop), logger.close().catch(noop)]); } } @@ -374,4 +399,4 @@ class ReplayPlaywrightReporter implements Reporter { } } -export default ReplayPlaywrightReporter; +function noop() {} diff --git a/packages/puppeteer/package.json b/packages/puppeteer/package.json index 1b6fabe56..210f81565 100644 --- a/packages/puppeteer/package.json +++ b/packages/puppeteer/package.json @@ -43,6 +43,7 @@ "is-uuid": "^1.0.2", "jsonata": "^1.8.6", "launchdarkly-node-client-sdk": "^3.2.1", + "mixpanel": "^0.18.0", "node-fetch": "^2.6.7", "p-map": "^4.0.0", "sha-1": "^1.0.0", diff --git a/packages/replayio/src/commands/record.ts b/packages/replayio/src/commands/record.ts index 1ebc8d4a9..23cdb4ae0 100644 --- a/packages/replayio/src/commands/record.ts +++ b/packages/replayio/src/commands/record.ts @@ -1,5 +1,5 @@ import { ProcessError } from "@replay-cli/shared/ProcessError"; -import { trackEvent } from "@replay-cli/shared/mixpanel/trackEvent"; +import { mixpanelAPI } from "@replay-cli/shared/mixpanel/mixpanelAPI"; import { exitProcess } from "@replay-cli/shared/process/exitProcess"; import { canUpload } from "@replay-cli/shared/recording/canUpload"; import { getRecordings } from "@replay-cli/shared/recording/getRecordings"; @@ -103,7 +103,7 @@ async function record(url: string = "about:blank") { console.log(""); // Spacing for readability } - trackEvent("record.results", { + mixpanelAPI.trackEvent("record.results", { crashedCount: crashedRecordings.length, successCountsByType: finishedRecordings.reduce( (map, recording) => { diff --git a/packages/replayio/src/utils/commander/registerCommand.ts b/packages/replayio/src/utils/commander/registerCommand.ts index 3efdcbe31..f9a8d5e24 100644 --- a/packages/replayio/src/utils/commander/registerCommand.ts +++ b/packages/replayio/src/utils/commander/registerCommand.ts @@ -1,4 +1,4 @@ -import { trackEvent } from "@replay-cli/shared/mixpanel/trackEvent"; +import { mixpanelAPI } from "@replay-cli/shared/mixpanel/mixpanelAPI"; import { program } from "commander"; import { initialize } from "../initialization/initialize"; @@ -17,7 +17,7 @@ export function registerCommand( } = config; return program.command(commandName).hook("preAction", async () => { - trackEvent("command", { commandName }); + mixpanelAPI.trackEvent("command", { commandName }); await initialize({ checkForNpmUpdate, diff --git a/packages/replayio/src/utils/initialization/checkForNpmUpdate.ts b/packages/replayio/src/utils/initialization/checkForNpmUpdate.ts index fdc6fd5f2..1dbcc5946 100644 --- a/packages/replayio/src/utils/initialization/checkForNpmUpdate.ts +++ b/packages/replayio/src/utils/initialization/checkForNpmUpdate.ts @@ -1,5 +1,5 @@ import { logger } from "@replay-cli/shared/logger"; -import { withTrackAsyncEvent } from "@replay-cli/shared/mixpanel/withTrackAsyncEvent"; +import { createAsyncFunctionWithTracking } from "@replay-cli/shared/mixpanel/createAsyncFunctionWithTracking"; import { fetch } from "undici"; import { version as currentVersion, name as packageName } from "../../../package.json"; import { shouldPrompt } from "../prompt/shouldPrompt"; @@ -7,7 +7,7 @@ import { UpdateCheck } from "./types"; const PROMPT_ID = "npm-update"; -export const checkForNpmUpdate = withTrackAsyncEvent( +export const checkForNpmUpdate = createAsyncFunctionWithTracking( async function checkForNpmUpdate(): Promise> { try { // https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-metadata-format diff --git a/packages/replayio/src/utils/initialization/checkForRuntimeUpdate.ts b/packages/replayio/src/utils/initialization/checkForRuntimeUpdate.ts index e15c8b4c3..afed6597e 100644 --- a/packages/replayio/src/utils/initialization/checkForRuntimeUpdate.ts +++ b/packages/replayio/src/utils/initialization/checkForRuntimeUpdate.ts @@ -1,5 +1,5 @@ import { logger } from "@replay-cli/shared/logger"; -import { withTrackAsyncEvent } from "@replay-cli/shared/mixpanel/withTrackAsyncEvent"; +import { createAsyncFunctionWithTracking } from "@replay-cli/shared/mixpanel/createAsyncFunctionWithTracking"; import { existsSync } from "fs-extra"; import { getBrowserPath } from "../browser/getBrowserPath"; import { getLatestRelease } from "../installation/getLatestReleases"; @@ -15,7 +15,7 @@ export type Version = { version: Release["version"]; }; -export const checkForRuntimeUpdate = withTrackAsyncEvent( +export const checkForRuntimeUpdate = createAsyncFunctionWithTracking( async function checkForRuntimeUpdate(): Promise> { let latestRelease: Release; let latestBuildId: string; diff --git a/packages/replayio/src/utils/initialization/initialize.ts b/packages/replayio/src/utils/initialization/initialize.ts index 7f6a566bd..01eeef1c2 100644 --- a/packages/replayio/src/utils/initialization/initialize.ts +++ b/packages/replayio/src/utils/initialization/initialize.ts @@ -1,7 +1,7 @@ import { raceWithTimeout } from "@replay-cli/shared/async/raceWithTimeout"; import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken"; import { initLaunchDarklyFromAccessToken } from "@replay-cli/shared/launch-darkly/initLaunchDarklyFromAccessToken"; -import { initMixpanelForUserSession } from "@replay-cli/shared/mixpanel/initMixpanelForUserSession"; +import { mixpanelAPI } from "@replay-cli/shared/mixpanel/mixpanelAPI"; import { name as packageName, version as packageVersion } from "../../../package.json"; import { logPromise } from "../async/logPromise"; import { checkForNpmUpdate } from "./checkForNpmUpdate"; @@ -60,10 +60,7 @@ export async function initialize({ : Promise.resolve(); const mixpanelPromise = raceWithTimeout( - initMixpanelForUserSession(accessToken, { - packageName, - packageVersion, - }), + mixpanelAPI.initialize({ accessToken, packageName, packageVersion }), 2_500, abortController ); diff --git a/packages/replayio/src/utils/initialization/promptForRuntimeUpdate.ts b/packages/replayio/src/utils/initialization/promptForRuntimeUpdate.ts index b423ffbc3..d428131ab 100644 --- a/packages/replayio/src/utils/initialization/promptForRuntimeUpdate.ts +++ b/packages/replayio/src/utils/initialization/promptForRuntimeUpdate.ts @@ -1,4 +1,4 @@ -import { trackEvent } from "@replay-cli/shared/mixpanel/trackEvent"; +import { mixpanelAPI } from "@replay-cli/shared/mixpanel/mixpanelAPI"; import { emphasize } from "@replay-cli/shared/theme"; import { name as packageName } from "../../../package.json"; import { installLatestRelease } from "../installation/installLatestRelease"; @@ -50,7 +50,7 @@ export async function promptForRuntimeUpdate(updateCheck: UpdateCheckResult { logger.info("InstallLatestRelease:Start"); const runtimeBaseDir = getReplayPath("runtimes"); diff --git a/packages/shared/src/mixpanel/close.ts b/packages/shared/src/mixpanel/close.ts deleted file mode 100644 index afcbcad47..000000000 --- a/packages/shared/src/mixpanel/close.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { timeoutAfter } from "../async/timeoutAfter"; -import { getPendingEvents } from "./pendingEvents"; - -export async function close() { - // Wait a short amount of time for pending Mixpanel events to be sent before exiting - await Promise.race([timeoutAfter(500, false), Promise.all(Array.from(getPendingEvents()))]); -} diff --git a/packages/shared/src/mixpanel/withTrackAsyncEvent.test.ts b/packages/shared/src/mixpanel/createAsyncFunctionWithTracking.test.ts similarity index 74% rename from packages/shared/src/mixpanel/withTrackAsyncEvent.test.ts rename to packages/shared/src/mixpanel/createAsyncFunctionWithTracking.test.ts index b65be8ffa..3aa808a74 100644 --- a/packages/shared/src/mixpanel/withTrackAsyncEvent.test.ts +++ b/packages/shared/src/mixpanel/createAsyncFunctionWithTracking.test.ts @@ -1,9 +1,10 @@ import { Deferred, createDeferred } from "../async/createDeferred"; -import { MixpanelAPI } from "./types"; +import type { mixpanelAPI as MixpanelAPIType, MixpanelImplementation } from "./mixpanelAPI"; -describe("withTrackAsyncEvent", () => { - let mockMixpanelAPI: MixpanelAPI; - let withTrackAsyncEvent: typeof import("./withTrackAsyncEvent").withTrackAsyncEvent; +describe("createAsyncFunctionWithTracking", () => { + let createAsyncFunctionWithTracking: typeof import("./createAsyncFunctionWithTracking").createAsyncFunctionWithTracking; + let mockMixpanelAPI: MixpanelImplementation; + let mixpanelAPI: typeof MixpanelAPIType; beforeEach(() => { mockMixpanelAPI = { @@ -11,14 +12,22 @@ describe("withTrackAsyncEvent", () => { track: jest.fn(), }; - // jest.resetModules does not work with import; only works with require() - withTrackAsyncEvent = require("./withTrackAsyncEvent").withTrackAsyncEvent; + jest.mock("../graphql/getAuthInfo", () => ({ + getAuthInfo: async () => ({ + id: "fake-session-id", + }), + })); - require("./session").configureSession("fake-user-id", { - packageName: "fake-name", + // jest.resetModules does not work with import; only works with require() + createAsyncFunctionWithTracking = + require("./createAsyncFunctionWithTracking").createAsyncFunctionWithTracking; + mixpanelAPI = require("./mixpanelAPI").mixpanelAPI; + mixpanelAPI.mockForTests(mockMixpanelAPI); + mixpanelAPI.initialize({ + accessToken: "fake-access-token", + packageName: "fake-package", packageVersion: "0.0.0", }); - require("./getMixpanelAPI").setMixpanelAPIForTests(mockMixpanelAPI); }); afterEach(() => { @@ -34,7 +43,7 @@ describe("withTrackAsyncEvent", () => { anotherProperty: "another", })); - const callbackWithTracking = withTrackAsyncEvent( + const callbackWithTracking = createAsyncFunctionWithTracking( () => deferred.promise, "test-event", mockGetProperties @@ -54,7 +63,7 @@ describe("withTrackAsyncEvent", () => { const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; expect(properties).toMatchObject({ anotherProperty: "another", - distinct_id: "fake-user-id", + distinct_id: "fake-session-id", error: undefined, result: "result", }); @@ -66,7 +75,7 @@ describe("withTrackAsyncEvent", () => { result, })); - const callbackWithTracking = withTrackAsyncEvent( + const callbackWithTracking = createAsyncFunctionWithTracking( (foo, bar) => Promise.resolve({ foo, bar }), "test-event", mockGetProperties @@ -80,7 +89,7 @@ describe("withTrackAsyncEvent", () => { const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; expect(properties).toMatchObject({ - distinct_id: "fake-user-id", + distinct_id: "fake-session-id", error: undefined, result: { foo: "abc", bar: 123 }, }); @@ -93,7 +102,7 @@ describe("withTrackAsyncEvent", () => { result, })); - const callbackWithTracking = withTrackAsyncEvent( + const callbackWithTracking = createAsyncFunctionWithTracking( () => { const deferred = createDeferred(); deferredArray.push(deferred); diff --git a/packages/shared/src/mixpanel/createAsyncFunctionWithTracking.ts b/packages/shared/src/mixpanel/createAsyncFunctionWithTracking.ts new file mode 100644 index 000000000..e84443099 --- /dev/null +++ b/packages/shared/src/mixpanel/createAsyncFunctionWithTracking.ts @@ -0,0 +1,10 @@ +import { mixpanelAPI, Properties } from "./mixpanelAPI"; + +export function createAsyncFunctionWithTracking, Type>( + createPromise: (...args: Params) => Promise, + eventName: string, + properties?: Properties | ((result: Type | undefined, error: any) => Properties) +): (...args: Params) => Promise { + return (...args: Params) => + mixpanelAPI.trackAsyncEvent(createPromise(...args), eventName, properties); +} diff --git a/packages/shared/src/mixpanel/getMixpanelAPI.ts b/packages/shared/src/mixpanel/getMixpanelAPI.ts deleted file mode 100644 index 144ffc191..000000000 --- a/packages/shared/src/mixpanel/getMixpanelAPI.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { init as initMixpanel } from "mixpanel"; -import { disableMixpanel, mixpanelToken } from "../config"; -import { MixpanelAPI } from "./types"; - -let mixpanelAPI: MixpanelAPI | undefined; - -export function getMixpanelAPI() { - if (!disableMixpanel) { - if (mixpanelAPI == null) { - mixpanelAPI = initMixpanel(mixpanelToken); - } - } - - return mixpanelAPI; -} - -export function setMixpanelAPIForTests(mock: MixpanelAPI | undefined) { - mixpanelAPI = mock; -} diff --git a/packages/shared/src/mixpanel/initMixpanelForUserSession.ts b/packages/shared/src/mixpanel/initMixpanelForUserSession.ts deleted file mode 100644 index 57fde49a7..000000000 --- a/packages/shared/src/mixpanel/initMixpanelForUserSession.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getAuthInfo } from "../graphql/getAuthInfo"; -import { logger } from "../logger"; -import { getMixpanelAPI } from "./getMixpanelAPI"; -import { configureSession } from "./session"; -import { DefaultProperties } from "./types"; - -export async function initMixpanelForUserSession( - accessToken: string | undefined, - defaultProperties: DefaultProperties -) { - let id: string | undefined = undefined; - - const mixpanelAPI = getMixpanelAPI(); - if (mixpanelAPI) { - logger.debug("Initializing Mixpanel user id"); - - if (accessToken) { - try { - const authInfo = await getAuthInfo(accessToken); - - logger.debug(`Found cached ${authInfo.type} id ${authInfo.id}`); - - id = authInfo.id; - } catch (error) {} - } - } - - configureSession(id, defaultProperties); -} diff --git a/packages/shared/src/mixpanel/mixpanelAPI.test.ts b/packages/shared/src/mixpanel/mixpanelAPI.test.ts new file mode 100644 index 000000000..f292f04ad --- /dev/null +++ b/packages/shared/src/mixpanel/mixpanelAPI.test.ts @@ -0,0 +1,426 @@ +import { Callback } from "mixpanel"; +import type { mixpanelAPI as MixpanelAPIType, MixpanelImplementation } from "./mixpanelAPI"; + +async function act(callback: () => void | Promise) { + await callback(); + await Promise.resolve(); +} + +describe("MixpanelAPI", () => { + let mockMixpanelAPI: MixpanelImplementation; + let mixpanelAPI: typeof MixpanelAPIType; + + const anyCallback = expect.any(Function); + const anyProperties = expect.any(Object); + + beforeEach(() => { + jest.useFakeTimers(); + + mockMixpanelAPI = { + init: jest.fn(), + track: jest.fn((_, __, callback) => { + callback?.(undefined); + }), + }; + + jest.mock("../graphql/getAuthInfo", () => ({ + getAuthInfo: async () => ({ + id: "fake-session-id", + }), + })); + + // jest.resetModules does not work with import; only works with require() + mixpanelAPI = require("./mixpanelAPI").mixpanelAPI; + mixpanelAPI.mockForTests(mockMixpanelAPI); + }); + + afterEach(() => { + jest.resetModules(); + }); + + describe("close", () => { + it("should flush pending requests before closing", async () => { + mixpanelAPI.initialize({ + accessToken: "fake-access-token", + packageName: "fake-package", + packageVersion: "0.0.0", + }); + + mixpanelAPI.trackEvent("pending-1"); + + expect(mixpanelAPI.pendingEventsCount).toBe(1); + + await act(() => mixpanelAPI.close()); + + expect(mixpanelAPI.pendingEventsCount).toBe(0); + }); + + it("should not hang when closing an uninitialized session", async () => { + mixpanelAPI.trackEvent("pending-1"); + mixpanelAPI.trackEvent("pending-2"); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); + + await act(() => mixpanelAPI.close()); + }); + }); + + describe("trackEvent", () => { + describe("unauthenticated", () => { + it("should not track any events until the user session has been identified", async () => { + mixpanelAPI.trackEvent("pending-1"); + mixpanelAPI.trackEvent("pending-2"); + mixpanelAPI.trackEvent("pending-3"); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); + + await act(() => { + mixpanelAPI.initialize({ + accessToken: "fake-access-token", + packageName: "fake-package", + packageVersion: "0.0.0", + }); + }); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(3); + + mixpanelAPI.trackEvent("unblocked-1"); + mixpanelAPI.trackEvent("unblocked-2"); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(5); + }); + + it("should track events after authentication fails", async () => { + mixpanelAPI.trackEvent("pending-1", { + packageName: "fake-package", + packageVersion: "0.0.0", + }); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); + + await act(() => { + mixpanelAPI.initialize({ + accessToken: undefined, + packageName: "fake-package", + packageVersion: "0.0.0", + }); + }); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); + + mixpanelAPI.trackEvent("unblocked-1"); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); + }); + + it("should still include non-user-specific default properties", async () => { + mixpanelAPI.trackEvent("fake-package.no-args"); + mixpanelAPI.trackEvent("fake-package.some-args", { foo: 123, bar: "abc" }); + + await act(() => { + mixpanelAPI.initialize({ + accessToken: undefined, + packageName: "fake-package", + packageVersion: "0.0.0", + }); + }); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 1, + "fake-package.no-args", + { packageName: "fake-package", packageVersion: "0.0.0" }, + anyCallback + ); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 2, + "fake-package.some-args", + { foo: 123, bar: "abc", packageName: "fake-package", packageVersion: "0.0.0" }, + anyCallback + ); + }); + }); + + describe("authenticated", () => { + beforeEach(() => { + mixpanelAPI.initialize({ + accessToken: "fake-access-token", + packageName: "fake-package", + packageVersion: "0.0.0", + }); + }); + + it("should enforce the package name prefix", async () => { + mixpanelAPI.trackEvent("has.no.prefix"); + mixpanelAPI.trackEvent("fake-package.has-prefix"); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 1, + "fake-package.has.no.prefix", + anyProperties, + anyCallback + ); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 2, + "fake-package.has-prefix", + anyProperties, + anyCallback + ); + }); + + it("should include additional user-specific default properties when authenticated", async () => { + mixpanelAPI.initialize({ + accessToken: "fake-access-token", + packageName: "fake-package", + packageVersion: "0.0.0", + }); + + mixpanelAPI.trackEvent("fake-package.no-args"); + mixpanelAPI.trackEvent("fake-package.some-args", { foo: 123, bar: "abc" }); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 1, + "fake-package.no-args", + { + distinct_id: "fake-session-id", + packageName: "fake-package", + packageVersion: "0.0.0", + }, + anyCallback + ); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 2, + "fake-package.some-args", + { + bar: "abc", + distinct_id: "fake-session-id", + foo: 123, + packageName: "fake-package", + packageVersion: "0.0.0", + }, + anyCallback + ); + }); + + it("should track pending promises until resolved or rejected", async () => { + const callbacks: Callback[] = []; + + (mockMixpanelAPI.track as jest.Mock).mockImplementation((_, __, callback) => { + callbacks.push(callback); + }); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); + + mixpanelAPI.trackEvent("should-resolve"); + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); + + mixpanelAPI.trackEvent("should-reject"); + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); + + expect(callbacks.length).toBe(2); + const successfulCallback = callbacks[0]!; + const unsuccessfulCallback = callbacks[1]!; + expect(mixpanelAPI.pendingEventsCount).toBe(2); + + unsuccessfulCallback(new Error("error")); + expect(mixpanelAPI.pendingEventsCount).toBe(1); + + successfulCallback(undefined); + expect(mixpanelAPI.pendingEventsCount).toBe(0); + }); + + describe("appendAdditionalProperties", () => { + it("should support appending additional default properties", async () => { + mixpanelAPI.initialize({ + accessToken: "fake-access-token", + packageName: "fake-package", + packageVersion: "0.0.0", + }); + + mixpanelAPI.trackEvent("fake-package.no-properties"); + mixpanelAPI.appendAdditionalProperties({ foo: 123 }); + mixpanelAPI.trackEvent("fake-package.some-properties"); + mixpanelAPI.appendAdditionalProperties({ bar: "abc" }); + mixpanelAPI.trackEvent("fake-package.some-more-properties"); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(3); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 1, + "fake-package.no-properties", + { + distinct_id: "fake-session-id", + packageName: "fake-package", + packageVersion: "0.0.0", + }, + anyCallback + ); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 2, + "fake-package.some-properties", + { + distinct_id: "fake-session-id", + foo: 123, + packageName: "fake-package", + packageVersion: "0.0.0", + }, + anyCallback + ); + expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( + 3, + "fake-package.some-more-properties", + { + bar: "abc", + distinct_id: "fake-session-id", + foo: 123, + packageName: "fake-package", + packageVersion: "0.0.0", + }, + anyCallback + ); + }); + }); + }); + }); + + describe("trackAsyncEvent", () => { + let createDeferred: typeof import("../async/createDeferred").createDeferred; + + beforeEach(async () => { + createDeferred = require("../async/createDeferred").createDeferred; + + await mixpanelAPI.initialize({ + accessToken: "fake-access-token", + packageName: "fake-package", + packageVersion: "0.0.0", + }); + }); + + it("should return the result of the promise after logging", async () => { + const deferred = createDeferred(); + const promise = mixpanelAPI.trackAsyncEvent(deferred.promise, "test"); + + deferred.resolve("resolution"); + + await expect(promise).resolves.toBe("resolution"); + }); + + it("should return the result of the a void promise after logging", async () => { + const deferred = createDeferred(); + const promise = mixpanelAPI.trackAsyncEvent(deferred.promise, "test"); + + deferred.resolve(); + + await expect(promise).resolves.toBe(undefined); + }); + + it("should re-throw a rejected promise error after logging", async () => { + const deferred = createDeferred(); + const promise = mixpanelAPI.trackAsyncEvent(deferred.promise, "test"); + + const error = new Error("error"); + deferred.reject(error); + + await expect(promise).rejects.toBe(error); + }); + + it("should log the duration and status (successful) of a successful promise", async () => { + const deferred = createDeferred(); + const promise = mixpanelAPI.trackAsyncEvent(deferred.promise, "test"); + + jest.advanceTimersByTime(2_500); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); + + deferred.resolve("resolution"); + + await expect(promise).resolves; + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); + + const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; + expect(properties).toMatchObject({ + duration: 2_500, + succeeded: true, + }); + }); + + it("should log the duration and status (unsuccessful) of a rejected promise", async () => { + const deferred = createDeferred(); + const promise = mixpanelAPI.trackAsyncEvent(deferred.promise, "test"); + + jest.advanceTimersByTime(5_000); + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); + + deferred.reject(new Error("error")); + + try { + await promise; + } catch (error) {} + + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); + + const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; + expect(properties).toMatchObject({ + duration: 5_000, + succeeded: false, + }); + }); + + it("should support lazy properties that depend on the result of a resolved promise", async () => { + const mockGetProperties = jest.fn(result => ({ + anotherProperty: "another", + result, + })); + + const deferred = createDeferred(); + const promise = mixpanelAPI.trackAsyncEvent(deferred.promise, "test", mockGetProperties); + + expect(mockGetProperties).not.toHaveBeenCalled(); + + deferred.resolve("resolution"); + + await expect(promise).resolves.toBe("resolution"); + + expect(mockGetProperties).toHaveBeenCalledTimes(1); + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); + + const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; + expect(properties).toMatchObject({ + anotherProperty: "another", + result: "resolution", + }); + }); + + it("should support lazy properties that depend on the result of a rejected promise", async () => { + const mockGetProperties = jest.fn((_, error) => ({ + anotherProperty: "another", + error, + })); + + const deferred = createDeferred(); + const promise = mixpanelAPI.trackAsyncEvent(deferred.promise, "test", mockGetProperties); + + expect(mockGetProperties).not.toHaveBeenCalled(); + + const error = new Error("error"); + + deferred.reject(error); + + try { + await promise; + } catch (error) {} + + expect(mockGetProperties).toHaveBeenCalledTimes(1); + expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); + + const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; + expect(properties).toMatchObject({ + anotherProperty: "another", + error, + }); + }); + }); +}); diff --git a/packages/shared/src/mixpanel/mixpanelAPI.ts b/packages/shared/src/mixpanel/mixpanelAPI.ts new file mode 100644 index 000000000..eb31b7349 --- /dev/null +++ b/packages/shared/src/mixpanel/mixpanelAPI.ts @@ -0,0 +1,197 @@ +import { Callback, PropertyDict, init as initMixpanel } from "mixpanel"; +import { STATUS_PENDING, createDeferred } from "../async/createDeferred"; +import { timeoutAfter } from "../async/timeoutAfter"; +import { disableMixpanel, mixpanelToken } from "../config"; +import { getAuthInfo } from "../graphql/getAuthInfo"; +import { logger } from "../logger"; + +export type Properties = Record; + +type MixpanelExternal = ReturnType; + +export type MixpanelImplementation = { + init: MixpanelExternal["init"]; + track: (eventName: string, properties: PropertyDict, callback: Callback) => void; +}; + +class MixpanelAPI { + private _additionalProperties: Properties = {}; + private _mixpanelAPI: MixpanelImplementation | undefined; + private _packageName: string | undefined; + private _packageVersion: string | undefined; + private _pendingEvents: Set> = new Set(); + private _sessionId: string | undefined; + private _waiter = createDeferred(); + + constructor() { + if (!disableMixpanel) { + this._mixpanelAPI = initMixpanel(mixpanelToken); + } + } + + appendAdditionalProperties(additionalProperties: Properties) { + Object.assign(this._additionalProperties, additionalProperties); + } + + async close() { + if (this._waiter.status !== STATUS_PENDING) { + await Promise.race([timeoutAfter(500, false), Promise.all(Array.from(this._pendingEvents))]); + } + } + + mockForTests(mock: MixpanelImplementation | undefined) { + this._mixpanelAPI = mock; + } + + async initialize({ + additionalProperties = {}, + accessToken, + packageName, + packageVersion, + }: { + additionalProperties?: Properties; + accessToken: string | undefined; + packageName: string; + packageVersion: string; + }) { + if (this._waiter.status !== STATUS_PENDING) { + logger.warn("Mixpanel already initialized", { + additionalProperties, + accessToken, + packageName, + packageVersion, + }); + + return; + } + + logger.debug("Initializing Mixpanel", { accessToken }); + + this._packageName = packageName; + this._packageVersion = packageVersion; + + Object.assign(this._additionalProperties, additionalProperties); + + if (accessToken) { + try { + const { id } = await getAuthInfo(accessToken); + + logger.debug(`Setting Mixpanel session id to ${id}`); + + this._sessionId = id; + } catch (error) { + logger.warn("Could not load Mixpanel session", { accessToken, error }); + } + } + + this._waiter.resolve(); + } + + get pendingEventsCount() { + return this._pendingEvents.size; + } + + async trackAsyncEvent( + promise: Promise, + eventName: string, + properties: Properties | ((result: Type | undefined, error: any) => Properties) = {} + ) { + if (!this._mixpanelAPI) { + return await promise; + } + + logger.debug(`Waiting to log Mixpanel event "${eventName}" (awaiting promise)`, { + eventName, + properties, + }); + + const startTime = Date.now(); + + let result: Type | undefined = undefined; + let succeeded = false; + let thrown: any = undefined; + try { + result = await promise; + + succeeded = true; + } catch (error) { + thrown = error; + } + + const endTime = Date.now(); + + this.trackEvent(eventName, { + ...(typeof properties === "function" ? properties(result, thrown) : properties), + duration: endTime - startTime, + succeeded, + }); + + if (succeeded) { + return result as Type; + } else { + throw thrown; + } + } + + trackEvent(eventName: string, properties: Properties = {}) { + if (!this._mixpanelAPI) { + return; + } + + if (this._packageName) { + const prefix = `${this._packageName}.`; + if (!eventName.startsWith(prefix)) { + eventName = prefix + eventName; + } + } + + logger.debug(`Logging Mixpanel event "${eventName}"`, { eventName, properties }); + + // This method does not await the deferred/promise + // because it is meant to be used in a fire-and-forget manner + // The application will wait for all pending events to be resolved before exiting + this._trackEventImplementation(eventName, properties); + } + + async waitForInitialization() { + return this._waiter.promise; + } + + private async _trackEventImplementation(eventName: string, properties: Properties) { + const deferredEvent = createDeferred(eventName); + + this._pendingEvents.add(deferredEvent.promise); + + // Wait until initialization completes before sending events + if (this._waiter.status === STATUS_PENDING) { + await this._waiter.promise; + } + + this._mixpanelAPI?.track( + eventName, + { + ...properties, + ...this._additionalProperties, + distinct_id: this._sessionId, + packageName: this._packageName, + packageVersion: this._packageVersion, + }, + (error: any) => { + if (error) { + logger.warn(`Mixpanel event "${eventName}" failed`, { eventName, error, properties }); + } else { + logger.debug(`Mixpanel event "${eventName}" successfully logged`, { + eventName, + properties, + }); + } + + deferredEvent.resolve(!error); + + this._pendingEvents.delete(deferredEvent.promise); + } + ); + } +} + +export const mixpanelAPI = new MixpanelAPI(); diff --git a/packages/shared/src/mixpanel/pendingEvents.ts b/packages/shared/src/mixpanel/pendingEvents.ts deleted file mode 100644 index 4a039533c..000000000 --- a/packages/shared/src/mixpanel/pendingEvents.ts +++ /dev/null @@ -1,13 +0,0 @@ -const pendingEvents: Set> = new Set(); - -export function addPendingEvent(promise: Promise) { - pendingEvents.add(promise); -} - -export function getPendingEvents() { - return pendingEvents; -} - -export function removePendingEvent(promise: Promise) { - pendingEvents.delete(promise); -} diff --git a/packages/shared/src/mixpanel/session.ts b/packages/shared/src/mixpanel/session.ts deleted file mode 100644 index 157896ad4..000000000 --- a/packages/shared/src/mixpanel/session.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createDeferred } from "../async/createDeferred"; -import { DefaultProperties } from "./types"; - -export const defaultProperties: DefaultProperties = { - packageName: "", - packageVersion: "", -}; - -export const deferredSession = createDeferred(); - -export function configureSession(id: string | undefined, properties: DefaultProperties) { - Object.assign(defaultProperties, properties); - - if (id) { - defaultProperties.distinct_id = id; - } - - deferredSession.resolveIfPending(id); -} diff --git a/packages/shared/src/mixpanel/trackAsyncEvent.test.ts b/packages/shared/src/mixpanel/trackAsyncEvent.test.ts deleted file mode 100644 index 1b79c080a..000000000 --- a/packages/shared/src/mixpanel/trackAsyncEvent.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createDeferred } from "../async/createDeferred"; -import { MixpanelAPI } from "./types"; - -describe("trackAsyncEvent", () => { - let mockMixpanelAPI: MixpanelAPI; - let configureSession: typeof import("./session").configureSession; - let trackAsyncEvent: typeof import("./trackAsyncEvent").trackAsyncEvent; - - beforeEach(() => { - mockMixpanelAPI = { - init: jest.fn(), - track: jest.fn(), - }; - - jest.useFakeTimers(); - - // jest.resetModules does not work with import; only works with require() - configureSession = require("./session").configureSession; - trackAsyncEvent = require("./trackAsyncEvent").trackAsyncEvent; - - require("./getMixpanelAPI").setMixpanelAPIForTests(mockMixpanelAPI); - - configureSession("fake-user-id", { - packageName: "fake-package", - packageVersion: "0.0.0", - }); - }); - - afterEach(() => { - jest.resetModules(); - }); - - it("should return the result of the promise after logging", async () => { - const deferred = createDeferred(); - const promise = trackAsyncEvent(deferred.promise, "test"); - - deferred.resolve("resolution"); - - await expect(promise).resolves.toBe("resolution"); - }); - - it("should return the result of the a void promise after logging", async () => { - const deferred = createDeferred(); - const promise = trackAsyncEvent(deferred.promise, "test"); - - deferred.resolve(); - - await expect(promise).resolves.toBe(undefined); - }); - - it("should re-throw a rejected promise error after logging", async () => { - const deferred = createDeferred(); - const promise = trackAsyncEvent(deferred.promise, "test"); - - const error = new Error("error"); - deferred.reject(error); - - await expect(promise).rejects.toBe(error); - }); - - it("should log the duration and status (successful) of a successful promise", async () => { - const deferred = createDeferred(); - const promise = trackAsyncEvent(deferred.promise, "test"); - - jest.advanceTimersByTime(2_500); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); - - deferred.resolve("resolution"); - - await expect(promise).resolves; - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); - - const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; - expect(properties).toMatchObject({ - duration: 2_500, - succeeded: true, - }); - }); - - it("should log the duration and status (unsuccessful) of a rejected promise", async () => { - const deferred = createDeferred(); - const promise = trackAsyncEvent(deferred.promise, "test"); - - jest.advanceTimersByTime(5_000); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); - - deferred.reject(new Error("error")); - - try { - await promise; - } catch (error) {} - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); - - const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; - expect(properties).toMatchObject({ - duration: 5_000, - succeeded: false, - }); - }); - - it("should support lazy properties that depend on the result of a resolved promise", async () => { - const mockGetProperties = jest.fn(result => ({ - anotherProperty: "another", - result, - })); - - const deferred = createDeferred(); - const promise = trackAsyncEvent(deferred.promise, "test", mockGetProperties); - - expect(mockGetProperties).not.toHaveBeenCalled(); - - deferred.resolve("resolution"); - - await expect(promise).resolves.toBe("resolution"); - - expect(mockGetProperties).toHaveBeenCalledTimes(1); - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); - - const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; - expect(properties).toMatchObject({ - anotherProperty: "another", - result: "resolution", - }); - }); - - it("should support lazy properties that depend on the result of a rejected promise", async () => { - const mockGetProperties = jest.fn((_, error) => ({ - anotherProperty: "another", - error, - })); - - const deferred = createDeferred(); - const promise = trackAsyncEvent(deferred.promise, "test", mockGetProperties); - - expect(mockGetProperties).not.toHaveBeenCalled(); - - const error = new Error("error"); - - deferred.reject(error); - - try { - await promise; - } catch (error) {} - - expect(mockGetProperties).toHaveBeenCalledTimes(1); - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); - - const properties = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][1]; - expect(properties).toMatchObject({ - anotherProperty: "another", - error, - }); - }); -}); diff --git a/packages/shared/src/mixpanel/trackAsyncEvent.ts b/packages/shared/src/mixpanel/trackAsyncEvent.ts deleted file mode 100644 index 4bdd19dd6..000000000 --- a/packages/shared/src/mixpanel/trackAsyncEvent.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { logger } from "../logger"; -import { trackEvent } from "./trackEvent"; -import { Properties } from "./types"; - -export async function trackAsyncEvent( - promise: Promise, - eventName: string, - properties?: Properties | ((result: Type | undefined, error: any) => Properties) -): Promise { - logger.debug(`trackAsyncEvent: "${eventName}" (awaiting promise)`, { eventName, properties }); - - const startTime = Date.now(); - - let result: Type | undefined = undefined; - let succeeded = false; - let thrown: any = undefined; - try { - result = await promise; - - succeeded = true; - } catch (error) { - thrown = error; - } - - const endTime = Date.now(); - - trackEvent(eventName, { - ...(typeof properties === "function" ? properties(result, thrown) : properties), - duration: endTime - startTime, - succeeded, - }); - - if (succeeded) { - return result as Type; - } else { - throw thrown; - } -} diff --git a/packages/shared/src/mixpanel/trackEvent.test.ts b/packages/shared/src/mixpanel/trackEvent.test.ts deleted file mode 100644 index 719d97c26..000000000 --- a/packages/shared/src/mixpanel/trackEvent.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { MixpanelAPI } from "./types"; - -async function act(callback: () => void | Promise) { - await callback(); - await Promise.resolve(); -} - -describe("trackEvent", () => { - let mockMixpanelAPI: MixpanelAPI; - let configureSession: typeof import("./session").configureSession; - let getPendingEvents: typeof import("./pendingEvents").getPendingEvents; - let trackEvent: typeof import("./trackEvent").trackEvent; - - const anyCallback = expect.any(Function); - const anyProperties = expect.any(Object); - - beforeEach(() => { - mockMixpanelAPI = { - init: jest.fn(), - track: jest.fn(), - }; - - // jest.resetModules does not work with import; only works with require() - configureSession = require("./session").configureSession; - getPendingEvents = require("./pendingEvents").getPendingEvents; - trackEvent = require("./trackEvent").trackEvent; - - require("./getMixpanelAPI").setMixpanelAPIForTests(mockMixpanelAPI); - }); - - afterEach(() => { - jest.resetModules(); - }); - - describe("unauthenticated", () => { - it("should not track any events until the user session has been identified", async () => { - trackEvent("pending-1"); - trackEvent("pending-2"); - trackEvent("pending-3"); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); - - await act(() => { - configureSession("fake-access-token", { - packageName: "fake-package", - packageVersion: "0.0.0", - }); - }); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(3); - - trackEvent("unblocked-1"); - trackEvent("unblocked-2"); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(5); - }); - - it("should track events after authentication fails", async () => { - trackEvent("pending-1", { - packageName: "fake-package", - packageVersion: "0.0.0", - }); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); - - await act(() => { - configureSession(undefined, { - packageName: "fake-package", - packageVersion: "0.0.0", - }); - }); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); - - trackEvent("unblocked-1"); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); - }); - - it("should still include non-user-specific default properties", async () => { - trackEvent("fake-package.no-args"); - trackEvent("fake-package.some-args", { foo: 123, bar: "abc" }); - - await act(() => { - configureSession(undefined, { - packageName: "fake-package", - packageVersion: "0.0.0", - }); - }); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); - expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( - 1, - "fake-package.no-args", - { packageName: "fake-package", packageVersion: "0.0.0" }, - anyCallback - ); - expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( - 2, - "fake-package.some-args", - { foo: 123, bar: "abc", packageName: "fake-package", packageVersion: "0.0.0" }, - anyCallback - ); - }); - }); - - describe("authenticated", () => { - beforeEach(() => { - configureSession("fake-user-id", { - packageName: "fake-package", - packageVersion: "0.0.0", - }); - }); - - it("should enforce the package name prefix", async () => { - trackEvent("has.no.prefix"); - trackEvent("fake-package.has-prefix"); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); - expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( - 1, - "fake-package.has.no.prefix", - anyProperties, - anyCallback - ); - expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( - 2, - "fake-package.has-prefix", - anyProperties, - anyCallback - ); - }); - - it("should include additional user-specific default properties when authenticated", async () => { - configureSession("fake-user-id", { - packageName: "fake-package", - packageVersion: "0.0.0", - }); - - trackEvent("fake-package.no-args"); - trackEvent("fake-package.some-args", { foo: 123, bar: "abc" }); - - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); - expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( - 1, - "fake-package.no-args", - { - distinct_id: "fake-user-id", - packageName: "fake-package", - packageVersion: "0.0.0", - }, - anyCallback - ); - expect(mockMixpanelAPI.track).toHaveBeenNthCalledWith( - 2, - "fake-package.some-args", - { - distinct_id: "fake-user-id", - foo: 123, - bar: "abc", - packageName: "fake-package", - packageVersion: "0.0.0", - }, - anyCallback - ); - }); - - it("should track pending promises until resolved or rejected", async () => { - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(0); - - trackEvent("should-resolve"); - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(1); - - trackEvent("should-reject"); - expect(mockMixpanelAPI.track).toHaveBeenCalledTimes(2); - - const successfulCallback = (mockMixpanelAPI.track as jest.Mock).mock.calls[0][2]; - const unsuccessfulCallback = (mockMixpanelAPI.track as jest.Mock).mock.calls[1][2]; - - expect(getPendingEvents().size).toBe(2); - - unsuccessfulCallback("error"); - expect(getPendingEvents().size).toBe(1); - - successfulCallback(); - expect(getPendingEvents().size).toBe(0); - }); - }); -}); diff --git a/packages/shared/src/mixpanel/trackEvent.ts b/packages/shared/src/mixpanel/trackEvent.ts deleted file mode 100644 index 502f4f8a3..000000000 --- a/packages/shared/src/mixpanel/trackEvent.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { STATUS_PENDING, createDeferred } from "../async/createDeferred"; -import { logger } from "../logger"; -import { getMixpanelAPI } from "./getMixpanelAPI"; -import { addPendingEvent, removePendingEvent } from "./pendingEvents"; -import { defaultProperties, deferredSession } from "./session"; -import { MixpanelAPI, Properties } from "./types"; - -export function trackEvent(eventName: string, properties: Properties = {}) { - const mixpanelAPI = getMixpanelAPI(); - if (!mixpanelAPI) { - return; - } - - const prefix = defaultProperties.packageName ? `${defaultProperties.packageName}.` : ""; - if (prefix && !eventName.startsWith(prefix)) { - eventName = prefix + eventName; - } - - logger.debug(`trackEvent: "${eventName}"`, { properties }); - - // This method does not await the deferred/promise - // because it is meant to be used in a fire-and-forget manner - // The application will wait for all pending events to be resolved before exiting - trackEventImplementation(mixpanelAPI, eventName, properties); -} - -async function trackEventImplementation( - mixpanelAPI: MixpanelAPI, - eventName: string, - properties: Properties -) { - const deferredEvent = createDeferred(eventName); - - addPendingEvent(deferredEvent.promise); - - // Wait until user auth completed before tracking events - if (deferredSession.status === STATUS_PENDING) { - await deferredSession.promise; - } - - mixpanelAPI.track( - eventName, - { - ...properties, - ...defaultProperties, - }, - (error: any) => { - if (error) { - logger.debug(`trackEvent: "${eventName}" -> failed:`, { error }); - } else { - logger.debug(`trackEvent: "${eventName}" -> success`); - } - - deferredEvent.resolve(!error); - - removePendingEvent(deferredEvent.promise); - } - ); -} diff --git a/packages/shared/src/mixpanel/types.ts b/packages/shared/src/mixpanel/types.ts deleted file mode 100644 index 65f61e19f..000000000 --- a/packages/shared/src/mixpanel/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { init as initMixpanel } from "mixpanel"; - -export type MixpanelAPI = Pick, "init" | "track">; - -export type DefaultProperties = { - packageName: string; - packageVersion: string; -} & Record; - -export type Properties = Record; diff --git a/packages/shared/src/mixpanel/withTrackAsyncEvent.ts b/packages/shared/src/mixpanel/withTrackAsyncEvent.ts deleted file mode 100644 index a49e3735c..000000000 --- a/packages/shared/src/mixpanel/withTrackAsyncEvent.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { trackAsyncEvent } from "./trackAsyncEvent"; -import { Properties } from "./types"; - -export function withTrackAsyncEvent, Type>( - createPromise: (...args: Params) => Promise, - eventName: string, - properties?: Properties | ((result: Type | undefined, error: any) => Properties) -): (...args: Params) => Promise { - return (...args: Params) => trackAsyncEvent(createPromise(...args), eventName, properties); -} diff --git a/packages/shared/src/process/exitTask.ts b/packages/shared/src/process/exitTask.ts index cf2fe1a1c..814cb15d2 100644 --- a/packages/shared/src/process/exitTask.ts +++ b/packages/shared/src/process/exitTask.ts @@ -1,10 +1,14 @@ import { close as finalizeLaunchDarkly } from "../launch-darkly/close"; -import { close as finalizeMixPanel } from "../mixpanel/close"; +import { mixpanelAPI } from "../mixpanel/mixpanelAPI"; import { logger } from "../logger"; export type ExitTask = () => Promise; -export const exitTasks: ExitTask[] = [finalizeLaunchDarkly, finalizeMixPanel, () => logger.close()]; +export const exitTasks: ExitTask[] = [ + finalizeLaunchDarkly, + () => mixpanelAPI.close(), + () => logger.close(), +]; export function registerExitTask(exitTask: ExitTask) { exitTasks.push(exitTask); diff --git a/packages/shared/src/recording/upload/uploadRecordings.ts b/packages/shared/src/recording/upload/uploadRecordings.ts index 9e40c44df..1b8ff17b8 100644 --- a/packages/shared/src/recording/upload/uploadRecordings.ts +++ b/packages/shared/src/recording/upload/uploadRecordings.ts @@ -1,6 +1,6 @@ import { getFeatureFlagValue } from "../../launch-darkly/getFeatureFlagValue"; import { logger } from "../../logger"; -import { withTrackAsyncEvent } from "../../mixpanel/withTrackAsyncEvent"; +import { createAsyncFunctionWithTracking } from "../../mixpanel/createAsyncFunctionWithTracking"; import { exitProcess } from "../../process/exitProcess"; import ProtocolClient from "../../protocol/ProtocolClient"; import { AUTHENTICATION_REQUIRED_ERROR_CODE, ProtocolError } from "../../protocol/ProtocolError"; @@ -15,7 +15,7 @@ import { ProcessingBehavior } from "./types"; import { uploadCrashedData } from "./uploadCrashData"; import { uploadRecording } from "./uploadRecording"; -export const uploadRecordings = withTrackAsyncEvent( +export const uploadRecordings = createAsyncFunctionWithTracking( async function uploadRecordings( recordings: LocalRecording[], options: { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 390bfa61b..df5a40761 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -32,6 +32,7 @@ "fs-extra": "^11.2.0", "jsonata": "^1.8.6", "launchdarkly-node-client-sdk": "^3.2.1", + "mixpanel": "^0.18.0", "node-fetch": "^2.6.7", "p-map": "^4.0.0", "query-registry": "^2.6.0", diff --git a/packages/test-utils/src/getAccessToken.ts b/packages/test-utils/src/getAccessToken.ts new file mode 100644 index 000000000..c680b0bf2 --- /dev/null +++ b/packages/test-utils/src/getAccessToken.ts @@ -0,0 +1,5 @@ +import { ReplayReporterConfig } from "./types"; + +export function getAccessToken(config: ReplayReporterConfig) { + return config.apiKey || process.env.REPLAY_API_KEY || process.env.RECORD_REPLAY_API_KEY; +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index d60993b80..9e8bc88d6 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -1,12 +1,13 @@ -import ReplayReporter from "./reporter"; - -export type { TestMetadataV1, TestMetadataV2, ReplayReporterConfig, PendingWork } from "./reporter"; -export { buildTestId } from "./testId"; -export { ReporterError } from "./reporter"; -export { pingTestMetrics } from "./metrics"; -export { removeAnsiCodes } from "./terminal"; +export type { TestMetadataV1, TestMetadataV2 } from "./legacy-cli/metadata/test"; export { fetchWorkspaceConfig } from "./config"; +export { getAccessToken } from "./getAccessToken"; export * from "./logging"; -export { ReplayReporter }; export { getMetadataFilePath, initMetadataFile } from "./metadata"; -export type { RecordingEntry } from "./types"; +export { pingTestMetrics } from "./metrics"; +export { ReporterError } from "./reporter"; +export type { PendingWork } from "./reporter"; +export { removeAnsiCodes } from "./terminal"; +export { buildTestId } from "./testId"; +export type { RecordingEntry, ReplayReporterConfig } from "./types"; +export { ReplayReporter }; +import ReplayReporter from "./reporter"; diff --git a/packages/test-utils/src/legacy-cli/main.ts b/packages/test-utils/src/legacy-cli/main.ts index 471f66063..a789767ea 100644 --- a/packages/test-utils/src/legacy-cli/main.ts +++ b/packages/test-utils/src/legacy-cli/main.ts @@ -26,7 +26,7 @@ import { } from "./types"; import { ReplayClient } from "./upload"; import { getDirectory, maybeLog } from "./utils"; -export type { BrowserName, RecordingEntry } from "./types"; +export type { RecordingEntry } from "./types"; export { updateStatus } from "./updateStatus"; const debug = dbg("replay:cli"); diff --git a/packages/test-utils/src/legacy-cli/types.ts b/packages/test-utils/src/legacy-cli/types.ts index d9f5380bd..564530a33 100644 --- a/packages/test-utils/src/legacy-cli/types.ts +++ b/packages/test-utils/src/legacy-cli/types.ts @@ -80,9 +80,6 @@ export interface UploadAllOptions extends FilterOptions, UploadOptions { /** * Supported replay browsers */ -export type BrowserName = "chromium" | "firefox"; - -export type Runner = "playwright" | "puppeteer"; export interface RecordingMetadata { recordingData: { diff --git a/packages/test-utils/src/reporter.ts b/packages/test-utils/src/reporter.ts index 6d6867c1e..71b5dc558 100644 --- a/packages/test-utils/src/reporter.ts +++ b/packages/test-utils/src/reporter.ts @@ -2,20 +2,22 @@ import { retryWithExponentialBackoff } from "@replay-cli/shared/async/retryOnFai import { getAuthInfo } from "@replay-cli/shared/graphql/getAuthInfo"; import { queryGraphQL } from "@replay-cli/shared/graphql/queryGraphQL"; import { logger } from "@replay-cli/shared/logger"; +import { Properties, mixpanelAPI } from "@replay-cli/shared/mixpanel/mixpanelAPI"; import { UnstructuredMetadata } from "@replay-cli/shared/recording/types"; import { spawnSync } from "child_process"; import { mkdirSync, writeFileSync } from "fs"; import assert from "node:assert/strict"; import { dirname } from "path"; import { v4 as uuid } from "uuid"; +import { getAccessToken } from "./getAccessToken"; import { listAllRecordings, removeRecording, uploadRecording } from "./legacy-cli"; import { add, source as sourceMetadata, test as testMetadata } from "./legacy-cli/metadata"; -import type { TestMetadataV1, TestMetadataV2 } from "./legacy-cli/metadata/test"; +import type { TestMetadataV2 } from "./legacy-cli/metadata/test"; import { log } from "./logging"; import { getMetadataFilePath } from "./metadata"; import { pingTestMetrics } from "./metrics"; import { buildTestId, generateOpaqueId } from "./testId"; -import { RecordingEntry } from "./types"; +import { RecordingEntry, ReplayReporterConfig, UploadStatusThreshold } from "./types"; function last(arr: T[]): T | undefined { return arr[arr.length - 1]; @@ -35,22 +37,8 @@ interface TestRunTestInputModel { recordingIds: string[]; } -export type UploadStatusThreshold = "all" | "failed-and-flaky" | "failed"; - type UploadStatusThresholdInternal = UploadStatusThreshold | "none"; -export type UploadOption = - | boolean - | { - /** - * Minimize the number of recordings uploaded for a test attempt (within a shard). - * e.g. Only one recording would be uploaded for a failing test attempt, regardless of retries. - * e.g. Two recordings would be uploaded for a flaky test attempt (the passing test and one of the failures). - */ - minimizeUploads?: boolean; - statusThreshold?: UploadStatusThreshold; - }; - interface UploadableTestExecutionResult { executionGroupId: string; attempt: number; @@ -69,29 +57,17 @@ interface UploadableTestResult executions: Record[]>; } -export interface ReplayReporterConfig< - TRecordingMetadata extends UnstructuredMetadata = UnstructuredMetadata -> { - runTitle?: string; - metadata?: Record | string; - metadataKey?: string; - upload?: UploadOption; - apiKey?: string; - /** @deprecated Use `upload.minimizeUploads` and `upload.statusThreshold` instead */ - filter?: (r: RecordingEntry) => boolean; -} - export interface TestRunner { name: string; version: string | undefined; plugin: string; } -type UserActionEvent = TestMetadataV2.UserActionEvent; -type Test = TestMetadataV2.Test; -type TestResult = TestMetadataV2.TestResult; -type TestError = TestMetadataV2.TestError; -type TestRun = TestMetadataV2.TestRun; +export type UserActionEvent = TestMetadataV2.UserActionEvent; +export type Test = TestMetadataV2.Test; +export type TestResult = TestMetadataV2.TestResult; +export type TestError = TestMetadataV2.TestError; +export type TestRun = TestMetadataV2.TestRun; type PendingWorkType = "test-run" | "test-run-tests" | "post-test" | "upload"; export type PendingWorkError = TErrorData & { @@ -238,7 +214,9 @@ function getFallbackRunTitle() { return `(local) ${gitChild.stdout.toString().trim()} branch`; } -class ReplayReporter { +export default class ReplayReporter< + TRecordingMetadata extends UnstructuredMetadata = UnstructuredMetadata +> { private _baseId = sourceMetadata.getTestRunIdFromEnvironment(process.env) || uuid(); private _testRunShardId: string | null = null; private _baseMetadata: Record | null = null; @@ -355,7 +333,7 @@ class ReplayReporter = {}, metadataKey?: string ) { - this._apiKey = config.apiKey || process.env.REPLAY_API_KEY || process.env.RECORD_REPLAY_API_KEY; + this._apiKey = getAccessToken(config); this._upload = "upload" in config ? !!config.upload : !!process.env.REPLAY_UPLOAD; if (this._upload && !this._apiKey) { throw new Error( @@ -417,12 +395,15 @@ class ReplayReporter, metadataKey?: string) { @@ -448,8 +431,18 @@ class ReplayReporter { logger.info("OnEnd:Started"); + mixpanelAPI.trackEvent("test-suite.ending", { + minimizeUploads: this._minimizeUploads, + numPendingWork: this._pendingWork.length, + uploadStatusThreshold: this._uploadStatusThreshold, + }); + await this._cacheAuthIdsPromise?.catch(e => { logger.error("OnEnd:AddingLoggerAuthFailed", { errorMessage: getErrorMessage(e), @@ -1203,6 +1214,9 @@ class ReplayReporter 0) { const recordingIds = uploads.map(u => u.recordingId).filter(isNonNullable); for (const recordingId of recordingIds) { @@ -1212,6 +1226,9 @@ class ReplayReporter u.status === "uploaded"); const crashed = uploads.filter(u => u.status === "crashUploaded"); + numCrashed = crashed.length; + numUploaded = uploaded.length; + if (uploaded.length > 0) { output.push(`\nšŸš€ Successfully uploaded ${uploads.length} recordings:\n`); const sortedUploads = sortRecordingsByResult(uploads); @@ -1233,11 +1250,14 @@ class ReplayReporter { + runTitle?: string; + metadata?: Record | string; + metadataKey?: string; + upload?: UploadOption; + apiKey?: string; + /** @deprecated Use `upload.minimizeUploads` and `upload.statusThreshold` instead */ + filter?: (r: RecordingEntry) => boolean; +} diff --git a/yarn.lock b/yarn.lock index 4a1d3bfbd..d88c7a7cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3506,6 +3506,7 @@ __metadata: is-uuid: "npm:^1.0.2" jsonata: "npm:^1.8.6" launchdarkly-node-client-sdk: "npm:^3.2.1" + mixpanel: "npm:^0.18.0" node-fetch: "npm:^2.6.7" p-map: "npm:^4.0.0" semver: "npm:^7.5.2" @@ -3545,6 +3546,7 @@ __metadata: jest-runtime: "npm:^27.5.1" jsonata: "npm:^1.8.6" launchdarkly-node-client-sdk: "npm:^3.2.1" + mixpanel: "npm:^0.18.0" node-fetch: "npm:^2.6.7" p-map: "npm:^4.0.0" sha-1: "npm:^1.0.0" @@ -3588,6 +3590,7 @@ __metadata: is-uuid: "npm:^1.0.2" jsonata: "npm:^1.8.6" launchdarkly-node-client-sdk: "npm:^3.2.1" + mixpanel: "npm:^0.18.0" node-fetch: "npm:^2.6.7" p-map: "npm:^4.0.0" sha-1: "npm:^1.0.0" @@ -3633,6 +3636,7 @@ __metadata: is-uuid: "npm:^1.0.2" jsonata: "npm:^1.8.6" launchdarkly-node-client-sdk: "npm:^3.2.1" + mixpanel: "npm:^0.18.0" node-fetch: "npm:^2.6.7" p-map: "npm:^4.0.0" sha-1: "npm:^1.0.0" @@ -3751,6 +3755,7 @@ __metadata: fs-extra: "npm:^11.2.0" jsonata: "npm:^1.8.6" launchdarkly-node-client-sdk: "npm:^3.2.1" + mixpanel: "npm:^0.18.0" node-fetch: "npm:^2.6.7" p-map: "npm:^4.0.0" query-registry: "npm:^2.6.0"