@trakoo/openpanel 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 trakoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @trakoo/openpanel
2
+
3
+ OpenPanel providers for [trakoo](https://www.npmjs.com/package/trakoo), the typed, provider-agnostic analytics library.
4
+
5
+ ## Installation
6
+
7
+ Install the package with only the OpenPanel SDK for the side you use:
8
+
9
+ ```bash
10
+ # Browser
11
+ pnpm add trakoo @trakoo/openpanel @openpanel/web
12
+
13
+ # Server
14
+ pnpm add trakoo @trakoo/openpanel @openpanel/sdk
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ `appEvents` is your event registry, created with `defineEvents()` from `trakoo`.
20
+
21
+ ```typescript title="lib/analytics.ts"
22
+ import { createClientAnalytics } from 'trakoo/client';
23
+ import { OpenPanelClientProvider } from '@trakoo/openpanel/client';
24
+ import { appEvents } from './events';
25
+
26
+ export const analytics = createClientAnalytics({
27
+ events: appEvents,
28
+ providers: [
29
+ new OpenPanelClientProvider({
30
+ clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID
31
+ })
32
+ ]
33
+ });
34
+ ```
35
+
36
+ ```typescript title="lib/server-analytics.ts"
37
+ import { createServerAnalytics } from 'trakoo/server';
38
+ import { OpenPanelServerProvider } from '@trakoo/openpanel/server';
39
+ import { appEvents } from './events';
40
+
41
+ export const serverAnalytics = createServerAnalytics({
42
+ events: appEvents,
43
+ providers: [
44
+ new OpenPanelServerProvider({
45
+ clientId: process.env.OPENPANEL_CLIENT_ID!,
46
+ clientSecret: process.env.OPENPANEL_CLIENT_SECRET!
47
+ })
48
+ ]
49
+ });
50
+ ```
51
+
52
+ Never put the client secret in browser code; `OpenPanelClientProvider` does not accept one.
53
+
54
+ ## Documentation
55
+
56
+ https://trakoo.co/docs/providers/openpanel
@@ -0,0 +1,33 @@
1
+ import { BaseAnalyticsProvider, type BaseEvent, type EventContext } from "trakoo";
2
+ import type { OpenPanelDeliveryFailureHandler } from "./transport.js";
3
+ import type { OpenPanelOptions as OpenPanelWebOptions } from "@openpanel/web";
4
+ export type OpenPanelClientConfig = Omit<OpenPanelWebOptions, "clientSecret" | "debug" | "disabled" | "sdk" | "sdkVersion" | "waitForProfile"> & {
5
+ clientId: string;
6
+ debug?: boolean;
7
+ enabled?: boolean;
8
+ /**
9
+ * Called when OpenPanel rejects an event. Without a handler the failure is
10
+ * logged, because the OpenPanel SDK drops rejected requests silently.
11
+ */
12
+ onDeliveryFailure?: OpenPanelDeliveryFailureHandler;
13
+ };
14
+ export type { OpenPanelDeliveryFailure, OpenPanelDeliveryFailureHandler, OpenPanelDeliveryFailureReason, } from "./transport.js";
15
+ export declare class OpenPanelClientProvider extends BaseAnalyticsProvider {
16
+ name: string;
17
+ private client?;
18
+ private trackEvent?;
19
+ private config;
20
+ private initialized;
21
+ private initPromise?;
22
+ private pendingActions;
23
+ constructor(config: OpenPanelClientConfig);
24
+ initialize(): Promise<void>;
25
+ private doInitialize;
26
+ private runWhenInitialized;
27
+ private flushPendingActions;
28
+ identify(userId: string, traits?: Record<string, unknown>): void;
29
+ track(event: BaseEvent, context?: EventContext): Promise<void>;
30
+ pageView(properties?: Record<string, unknown>, context?: EventContext): void;
31
+ pageLeave(_properties?: Record<string, unknown>, _context?: EventContext): void;
32
+ reset(): void;
33
+ }
package/dist/client.js ADDED
@@ -0,0 +1,135 @@
1
+ import { BaseAnalyticsProvider, } from "trakoo";
2
+ import { buildEventProperties, buildIdentifyPayload, buildTrackedEventProperties, } from "./shared.js";
3
+ import { createDeliveryFailureReporter, instrumentOpenPanelDelivery, } from "./transport.js";
4
+ import { isBrowser } from "./environment.js";
5
+ export class OpenPanelClientProvider extends BaseAnalyticsProvider {
6
+ name = "OpenPanel-Client";
7
+ client;
8
+ trackEvent;
9
+ config;
10
+ initialized = false;
11
+ initPromise;
12
+ pendingActions = [];
13
+ constructor(config) {
14
+ super({ debug: config.debug, enabled: config.enabled });
15
+ this.config = config;
16
+ }
17
+ initialize() {
18
+ if (!this.isEnabled() || this.initialized)
19
+ return Promise.resolve();
20
+ if (this.initPromise)
21
+ return this.initPromise;
22
+ this.initPromise = this.doInitialize();
23
+ return this.initPromise;
24
+ }
25
+ async doInitialize() {
26
+ if (!isBrowser()) {
27
+ this.log("Skipping initialization - not in browser environment");
28
+ return;
29
+ }
30
+ if (!this.config.clientId || typeof this.config.clientId !== "string") {
31
+ this.initPromise = undefined;
32
+ throw new Error("OpenPanel requires a clientId");
33
+ }
34
+ try {
35
+ const { OpenPanel, OpenPanelBase } = await import("@openpanel/web");
36
+ const runtimeConfig = this.config;
37
+ const { enabled, clientSecret, disabled, onDeliveryFailure, sdk, sdkVersion, waitForProfile, ...options } = runtimeConfig;
38
+ void enabled;
39
+ void clientSecret;
40
+ void disabled;
41
+ void sdk;
42
+ void sdkVersion;
43
+ void waitForProfile;
44
+ this.client = new OpenPanel({
45
+ trackAttributes: false,
46
+ trackOutgoingLinks: false,
47
+ trackScreenViews: false,
48
+ ...options,
49
+ debug: this.config.debug ?? false,
50
+ });
51
+ const instrumented = instrumentOpenPanelDelivery(this.client, createDeliveryFailureReporter(this.name, onDeliveryFailure));
52
+ if (!instrumented) {
53
+ console.warn("[OpenPanel-Client] Unrecognized @openpanel/web transport - delivery failure reporting is unavailable");
54
+ }
55
+ // The web override replaces a caller-supplied __path with its last screen view.
56
+ this.trackEvent = OpenPanelBase.prototype.track.bind(this.client);
57
+ this.initialized = true;
58
+ this.flushPendingActions();
59
+ this.log("Initialized successfully");
60
+ }
61
+ catch (error) {
62
+ this.initPromise = undefined;
63
+ this.pendingActions = [];
64
+ console.error(`[OpenPanel-Client] Failed to initialize (${this.getErrorClass(error)})`);
65
+ throw error;
66
+ }
67
+ }
68
+ runWhenInitialized(action) {
69
+ if (!this.isEnabled())
70
+ return;
71
+ if (this.initialized && this.client) {
72
+ action();
73
+ return;
74
+ }
75
+ if (this.initPromise) {
76
+ this.pendingActions.push(action);
77
+ }
78
+ }
79
+ flushPendingActions() {
80
+ const actions = this.pendingActions;
81
+ this.pendingActions = [];
82
+ for (const action of actions) {
83
+ try {
84
+ action();
85
+ }
86
+ catch (error) {
87
+ console.error(`[OpenPanel-Client] Pending action failed (${this.getErrorClass(error)})`);
88
+ }
89
+ }
90
+ }
91
+ identify(userId, traits) {
92
+ this.runWhenInitialized(() => {
93
+ const pending = this.client?.identify(buildIdentifyPayload(userId, traits));
94
+ if (pending) {
95
+ void pending.catch((error) => {
96
+ console.error(`[OpenPanel-Client] Failed to identify user (${this.getErrorClass(error)})`);
97
+ });
98
+ }
99
+ this.log("Identified user");
100
+ });
101
+ }
102
+ async track(event, context) {
103
+ if (!this.isEnabled())
104
+ return;
105
+ if (!this.initialized && this.initPromise)
106
+ await this.initPromise;
107
+ if (!this.initialized || !this.trackEvent)
108
+ return;
109
+ await this.trackEvent(event.action, buildTrackedEventProperties(event, context));
110
+ this.log("Tracked event");
111
+ }
112
+ pageView(properties, context) {
113
+ this.runWhenInitialized(() => {
114
+ const pageProperties = buildEventProperties(properties, context, {});
115
+ const path = context?.page?.url ?? context?.page?.path;
116
+ if (path) {
117
+ this.client?.screenView(path, pageProperties);
118
+ }
119
+ else {
120
+ this.client?.screenView(pageProperties);
121
+ }
122
+ this.log("Tracked page view");
123
+ });
124
+ }
125
+ pageLeave(_properties, _context) {
126
+ this.log("Page leave is not supported by OpenPanel");
127
+ }
128
+ reset() {
129
+ this.pendingActions = [];
130
+ if (!this.isEnabled() || !this.initialized || !this.client)
131
+ return;
132
+ this.client.clear();
133
+ this.log("Cleared user identity");
134
+ }
135
+ }
@@ -0,0 +1 @@
1
+ export declare function isBrowser(): boolean;
@@ -0,0 +1,3 @@
1
+ export function isBrowser() {
2
+ return (typeof window !== "undefined" && typeof window.document !== "undefined");
3
+ }
@@ -0,0 +1,34 @@
1
+ import { BaseAnalyticsProvider, type BaseEvent, type EventContext } from "trakoo";
2
+ import type { OpenPanelDeliveryFailureHandler } from "./transport.js";
3
+ import type { OpenPanelOptions } from "@openpanel/sdk";
4
+ export interface OpenPanelServerConfig {
5
+ clientId: string;
6
+ clientSecret: string;
7
+ apiUrl?: string;
8
+ filter?: OpenPanelOptions["filter"];
9
+ debug?: boolean;
10
+ enabled?: boolean;
11
+ /**
12
+ * Called when OpenPanel rejects an event. Without a handler the failure is
13
+ * logged, because the OpenPanel SDK drops rejected requests silently.
14
+ */
15
+ onDeliveryFailure?: OpenPanelDeliveryFailureHandler;
16
+ }
17
+ export type { OpenPanelDeliveryFailure, OpenPanelDeliveryFailureHandler, OpenPanelDeliveryFailureReason, } from "./transport.js";
18
+ export declare class OpenPanelServerProvider extends BaseAnalyticsProvider {
19
+ name: string;
20
+ private client?;
21
+ private config;
22
+ private initialized;
23
+ private initializePromise?;
24
+ constructor(config: OpenPanelServerConfig);
25
+ initialize(): Promise<void>;
26
+ private initializeOpenPanel;
27
+ private reportDeliveryFailures;
28
+ identify(userId: string, traits?: Record<string, unknown>): Promise<void>;
29
+ track(event: BaseEvent, context?: EventContext): Promise<void>;
30
+ pageView(properties?: Record<string, unknown>, context?: EventContext): Promise<void>;
31
+ pageLeave(_properties?: Record<string, unknown>, _context?: EventContext): void;
32
+ reset(): void;
33
+ shutdown(): void;
34
+ }
package/dist/server.js ADDED
@@ -0,0 +1,133 @@
1
+ import { BaseAnalyticsProvider, } from "trakoo";
2
+ import { buildEventProperties, buildIdentifyPayload, buildTrackedEventProperties, withRequestContext, } from "./shared.js";
3
+ import { createDeliveryFailureReporter, instrumentOpenPanelDelivery, } from "./transport.js";
4
+ const isMissingPackageError = (error, packageName) => {
5
+ try {
6
+ if (!error || typeof error !== "object")
7
+ return false;
8
+ const code = Reflect.get(error, "code");
9
+ const message = Reflect.get(error, "message");
10
+ if ((code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") ||
11
+ typeof message !== "string") {
12
+ return false;
13
+ }
14
+ return (message.includes(`Cannot find package '${packageName}'`) ||
15
+ message.includes(`Cannot find module '${packageName}'`));
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ };
21
+ export class OpenPanelServerProvider extends BaseAnalyticsProvider {
22
+ name = "OpenPanel-Server";
23
+ client;
24
+ config;
25
+ initialized = false;
26
+ initializePromise;
27
+ constructor(config) {
28
+ super({ debug: config.debug, enabled: config.enabled });
29
+ this.config = config;
30
+ }
31
+ initialize() {
32
+ if (!this.isEnabled() || this.initialized)
33
+ return Promise.resolve();
34
+ if (this.initializePromise)
35
+ return this.initializePromise;
36
+ this.initializePromise = this.initializeOpenPanel().catch((error) => {
37
+ this.initializePromise = undefined;
38
+ console.error(`[OpenPanel-Server] Failed to initialize (${this.getErrorClass(error)})`);
39
+ throw error;
40
+ });
41
+ return this.initializePromise;
42
+ }
43
+ async initializeOpenPanel() {
44
+ if (!this.config.clientId || typeof this.config.clientId !== "string") {
45
+ throw new Error("OpenPanel requires a clientId");
46
+ }
47
+ if (!this.config.clientSecret ||
48
+ typeof this.config.clientSecret !== "string") {
49
+ throw new Error("OpenPanel requires a clientSecret on the server");
50
+ }
51
+ let OpenPanelClient;
52
+ try {
53
+ ({ OpenPanel: OpenPanelClient } = await import("@openpanel/sdk"));
54
+ }
55
+ catch (error) {
56
+ if (isMissingPackageError(error, "@openpanel/sdk")) {
57
+ throw new Error("OpenPanel server provider requires the optional peer package @openpanel/sdk");
58
+ }
59
+ throw error;
60
+ }
61
+ // Built from the documented options alone. An untyped `disabled` or
62
+ // `waitForProfile` makes the shared client queue events and release
63
+ // them under whichever request identifies next.
64
+ const { apiUrl, clientId, clientSecret, debug, filter, onDeliveryFailure } = this.config;
65
+ this.client = new OpenPanelClient({
66
+ clientId,
67
+ clientSecret,
68
+ ...(apiUrl !== undefined && { apiUrl }),
69
+ ...(debug !== undefined && { debug }),
70
+ ...(filter !== undefined && { filter }),
71
+ });
72
+ this.reportDeliveryFailures(onDeliveryFailure);
73
+ this.initialized = true;
74
+ this.log("Initialized successfully");
75
+ }
76
+ reportDeliveryFailures(onDeliveryFailure) {
77
+ const instrumented = instrumentOpenPanelDelivery(this.client, createDeliveryFailureReporter(this.name, onDeliveryFailure));
78
+ if (!instrumented) {
79
+ // Attribution rides on this transport, so losing it must not be quiet.
80
+ console.warn("[OpenPanel-Server] Unrecognized @openpanel/sdk transport - caller IP and user agent attribution and delivery failure reporting are unavailable");
81
+ }
82
+ }
83
+ async identify(userId, traits) {
84
+ const client = this.isEnabled() && this.initialized ? this.client : undefined;
85
+ if (!client)
86
+ return;
87
+ let pending;
88
+ try {
89
+ pending = client.identify(buildIdentifyPayload(userId, traits));
90
+ }
91
+ finally {
92
+ client.clear();
93
+ }
94
+ await pending;
95
+ this.log("Updated user profile");
96
+ }
97
+ async track(event, context) {
98
+ const client = this.isEnabled() && this.initialized ? this.client : undefined;
99
+ if (!client)
100
+ return;
101
+ await client.track(event.action, withRequestContext(buildTrackedEventProperties(event, context), context));
102
+ this.log("Tracked event");
103
+ }
104
+ async pageView(properties, context) {
105
+ const client = this.isEnabled() && this.initialized ? this.client : undefined;
106
+ if (!client)
107
+ return;
108
+ await client.track("screen_view", withRequestContext(buildEventProperties(properties, context, {
109
+ category: "navigation",
110
+ userId: context?.user?.userId,
111
+ }), context));
112
+ this.log("Tracked page view");
113
+ }
114
+ pageLeave(_properties, _context) {
115
+ this.log("Page leave is not supported by OpenPanel");
116
+ }
117
+ reset() {
118
+ if (!this.isEnabled() || !this.initialized || !this.client)
119
+ return;
120
+ this.client.clear();
121
+ this.log("Cleared user identity");
122
+ }
123
+ shutdown() {
124
+ const client = this.isEnabled() && this.initialized ? this.client : undefined;
125
+ if (!client)
126
+ return;
127
+ client.clear();
128
+ this.client = undefined;
129
+ this.initialized = false;
130
+ this.initializePromise = undefined;
131
+ this.log("Shutdown complete");
132
+ }
133
+ }
@@ -0,0 +1,28 @@
1
+ import type { BaseEvent, EventContext } from "trakoo";
2
+ import { type OpenPanelRequestContext } from "./transport.js";
3
+ import type { IdentifyPayload } from "@openpanel/sdk";
4
+ export declare function buildIdentifyPayload(userId: string, traits?: Record<string, unknown>): IdentifyPayload;
5
+ export declare function buildEventProperties(properties: Record<string, unknown> | undefined, context: EventContext | undefined, metadata: {
6
+ category?: string;
7
+ timestamp?: number;
8
+ userId?: string;
9
+ sessionId?: string;
10
+ }): Record<string, unknown>;
11
+ export declare function buildTrackedEventProperties(event: BaseEvent, context?: EventContext): Record<string, unknown>;
12
+ /**
13
+ * Collects the attributes OpenPanel resolves from request headers rather than
14
+ * from the event body: the caller's IP for geo, its user agent for the device.
15
+ * `server` is the request-scoped source a server caller populates; `device` is
16
+ * the fallback for callers that already put the visitor there.
17
+ */
18
+ export declare function buildRequestContext(context: EventContext | undefined): OpenPanelRequestContext | undefined;
19
+ /**
20
+ * Parks the request attributes on the payload for the delivery transport to
21
+ * move onto this one request's headers, and drops the IP `context.device`
22
+ * contributed to the `device` property: geo belongs to the request, and a raw
23
+ * address stored on every event is a liability the header avoids.
24
+ *
25
+ * Only the server provider applies this. A browser sends its own headers, and
26
+ * `user-agent` is forbidden to `fetch()` there.
27
+ */
28
+ export declare function withRequestContext(properties: Record<string, unknown>, context: EventContext | undefined): Record<string, unknown>;
package/dist/shared.js ADDED
@@ -0,0 +1,114 @@
1
+ import { REQUEST_CONTEXT } from "./transport.js";
2
+ const PROFILE_FIELDS = ["firstName", "lastName", "email", "avatar"];
3
+ /**
4
+ * The bytes Node's `fetch` accepts in a header value. It refuses anything else
5
+ * before the request leaves, and the transport cannot tell that refusal from a
6
+ * network failure: one malformed attribute would retry and then lose the
7
+ * whole event. A value outside this set is skipped instead.
8
+ */
9
+ const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]+$/;
10
+ export function buildIdentifyPayload(userId, traits) {
11
+ const payload = { profileId: userId };
12
+ const properties = {};
13
+ for (const [key, value] of Object.entries(traits ?? {})) {
14
+ if (PROFILE_FIELDS.includes(key) &&
15
+ typeof value === "string") {
16
+ if (key === "firstName")
17
+ payload.firstName = value;
18
+ if (key === "lastName")
19
+ payload.lastName = value;
20
+ if (key === "email")
21
+ payload.email = value;
22
+ if (key === "avatar")
23
+ payload.avatar = value;
24
+ }
25
+ else {
26
+ properties[key] = value;
27
+ }
28
+ }
29
+ if (Object.keys(properties).length > 0) {
30
+ payload.properties = properties;
31
+ }
32
+ return payload;
33
+ }
34
+ export function buildEventProperties(properties, context, metadata) {
35
+ const pagePath = context?.page?.url ?? context?.page?.path;
36
+ return {
37
+ ...properties,
38
+ ...(metadata.category && { category: metadata.category }),
39
+ ...(metadata.timestamp !== undefined && {
40
+ __timestamp: new Date(metadata.timestamp).toISOString(),
41
+ }),
42
+ ...(metadata.userId && { profileId: metadata.userId }),
43
+ ...(metadata.sessionId && { sessionId: metadata.sessionId }),
44
+ ...(pagePath && { __path: pagePath }),
45
+ ...(context?.page?.title && { __title: context.page.title }),
46
+ ...(context?.page?.referrer && { __referrer: context.page.referrer }),
47
+ ...(context?.page && { page: context.page }),
48
+ ...(context?.device && { device: context.device }),
49
+ ...(context?.utm && { utm: context.utm }),
50
+ ...(context?.user?.email && { user_email: context.user.email }),
51
+ ...(context?.user?.traits && { user_traits: context.user.traits }),
52
+ };
53
+ }
54
+ export function buildTrackedEventProperties(event, context) {
55
+ return buildEventProperties(event.properties, context, {
56
+ category: event.category,
57
+ timestamp: event.timestamp,
58
+ userId: event.userId ?? context?.user?.userId,
59
+ sessionId: event.sessionId,
60
+ });
61
+ }
62
+ /**
63
+ * Collects the attributes OpenPanel resolves from request headers rather than
64
+ * from the event body: the caller's IP for geo, its user agent for the device.
65
+ * `server` is the request-scoped source a server caller populates; `device` is
66
+ * the fallback for callers that already put the visitor there.
67
+ */
68
+ export function buildRequestContext(context) {
69
+ const ip = firstHeaderValue(context?.server?.ip, context?.device?.ip);
70
+ const userAgent = firstHeaderValue(context?.server?.userAgent, context?.device?.userAgent);
71
+ if (!ip && !userAgent)
72
+ return undefined;
73
+ return { ...(ip && { ip }), ...(userAgent && { userAgent }) };
74
+ }
75
+ /**
76
+ * Parks the request attributes on the payload for the delivery transport to
77
+ * move onto this one request's headers, and drops the IP `context.device`
78
+ * contributed to the `device` property: geo belongs to the request, and a raw
79
+ * address stored on every event is a liability the header avoids.
80
+ *
81
+ * Only the server provider applies this. A browser sends its own headers, and
82
+ * `user-agent` is forbidden to `fetch()` there.
83
+ */
84
+ export function withRequestContext(properties, context) {
85
+ const requestContext = buildRequestContext(context);
86
+ // Only the address copied out of `context.device` is removed. A `device`
87
+ // the event declared itself is its own data, and an IP promoted from
88
+ // `context.server` says nothing about it. The address is removed even when
89
+ // it could not be promoted, so a malformed one is not stored instead.
90
+ const stripped = typeof context?.device?.ip === "string"
91
+ ? withoutContextDeviceIp(properties)
92
+ : properties;
93
+ return requestContext
94
+ ? { ...stripped, [REQUEST_CONTEXT]: requestContext }
95
+ : stripped;
96
+ }
97
+ function withoutContextDeviceIp(properties) {
98
+ const { device: _contextDevice, ...rest } = properties;
99
+ const device = withoutIp(properties.device);
100
+ return { ...rest, ...(device !== undefined && { device }) };
101
+ }
102
+ function withoutIp(device) {
103
+ if (!device || typeof device !== "object")
104
+ return device;
105
+ const { ip: _promoted, ...rest } = device;
106
+ return Object.keys(rest).length > 0 ? rest : undefined;
107
+ }
108
+ function firstHeaderValue(...values) {
109
+ for (const value of values) {
110
+ if (typeof value === "string" && HEADER_VALUE.test(value))
111
+ return value;
112
+ }
113
+ return undefined;
114
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * OpenPanel's SDKs treat HTTP 401 as a non-error: their shared `Api.post`
3
+ * returns `null` without throwing, retrying or logging, so a wrong, rotated or
4
+ * origin-rejected key stops analytics silently. Both the browser and the server
5
+ * SDK send through the `api` instance they expose publicly, so trakoo replaces
6
+ * that instance's `fetch` with an equivalent implementation that reports the
7
+ * response status before returning the value the SDK expects.
8
+ *
9
+ * The replacement mirrors the SDK transport: 200 and 202 are the only success
10
+ * statuses, 401 is never retried, every other failure backs off exponentially,
11
+ * and headers, body and request options are built the same way. Delivery still
12
+ * resolves rather than throwing, so reporting a failure can never turn a
13
+ * tracked event into an application error.
14
+ */
15
+ export type OpenPanelDeliveryFailureReason = "network_error" | "server_error" | "unauthorized";
16
+ export interface OpenPanelDeliveryFailure {
17
+ /** Requests made for this event, including the one that failed. */
18
+ readonly attempts: number;
19
+ /** OpenPanel envelope type such as `track` or `identify`, never a payload. */
20
+ readonly payloadType?: string;
21
+ readonly reason: OpenPanelDeliveryFailureReason;
22
+ /** Absent when the request never produced a response. */
23
+ readonly status?: number;
24
+ /** Ingestion endpoint the event was sent to. */
25
+ readonly url: string;
26
+ }
27
+ export type OpenPanelDeliveryFailureHandler = (failure: OpenPanelDeliveryFailure) => void;
28
+ /**
29
+ * Request attributes OpenPanel reads from headers rather than from the event
30
+ * body: the caller's IP resolves geo, and the user agent resolves the device.
31
+ */
32
+ export interface OpenPanelRequestContext {
33
+ readonly ip?: string;
34
+ readonly userAgent?: string;
35
+ }
36
+ /**
37
+ * Where the server provider parks {@link OpenPanelRequestContext} on the
38
+ * payload it hands the SDK.
39
+ *
40
+ * OpenPanel's own server integrations build a fresh client per request and
41
+ * call `api.addHeader()` on it. A trakoo provider is long-lived and shared by
42
+ * every concurrent request, so mutating those headers would attribute one
43
+ * caller's event to another caller's IP. Carrying the values on the payload
44
+ * keeps them bound to the single event they describe and survives the SDK's
45
+ * internal queue, which spreads the properties object.
46
+ *
47
+ * The key is a symbol, so no event property can impersonate it and turn
48
+ * tracked data into request headers. It also cannot be serialized:
49
+ * `JSON.stringify` drops symbol keys, so the carrier never reaches OpenPanel
50
+ * as a property even when this transport is not installed.
51
+ */
52
+ export declare const REQUEST_CONTEXT: unique symbol;
53
+ /**
54
+ * Replaces the transport of an OpenPanel client so rejected requests are
55
+ * reported. Returns `false` when the client does not expose the expected
56
+ * transport, in which case the SDK keeps delivering events unchanged.
57
+ */
58
+ export declare function instrumentOpenPanelDelivery(client: unknown, report: OpenPanelDeliveryFailureHandler): boolean;
59
+ /**
60
+ * Wraps a caller-supplied handler so it can never fail an analytics call, and
61
+ * logs the failure when no handler is configured. OpenPanel drops rejected
62
+ * requests silently, so a missing handler must not mean missing signal.
63
+ */
64
+ export declare function createDeliveryFailureReporter(providerName: string, onDeliveryFailure: OpenPanelDeliveryFailureHandler | undefined): OpenPanelDeliveryFailureHandler;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * OpenPanel's SDKs treat HTTP 401 as a non-error: their shared `Api.post`
3
+ * returns `null` without throwing, retrying or logging, so a wrong, rotated or
4
+ * origin-rejected key stops analytics silently. Both the browser and the server
5
+ * SDK send through the `api` instance they expose publicly, so trakoo replaces
6
+ * that instance's `fetch` with an equivalent implementation that reports the
7
+ * response status before returning the value the SDK expects.
8
+ *
9
+ * The replacement mirrors the SDK transport: 200 and 202 are the only success
10
+ * statuses, 401 is never retried, every other failure backs off exponentially,
11
+ * and headers, body and request options are built the same way. Delivery still
12
+ * resolves rather than throwing, so reporting a failure can never turn a
13
+ * tracked event into an application error.
14
+ */
15
+ /**
16
+ * Where the server provider parks {@link OpenPanelRequestContext} on the
17
+ * payload it hands the SDK.
18
+ *
19
+ * OpenPanel's own server integrations build a fresh client per request and
20
+ * call `api.addHeader()` on it. A trakoo provider is long-lived and shared by
21
+ * every concurrent request, so mutating those headers would attribute one
22
+ * caller's event to another caller's IP. Carrying the values on the payload
23
+ * keeps them bound to the single event they describe and survives the SDK's
24
+ * internal queue, which spreads the properties object.
25
+ *
26
+ * The key is a symbol, so no event property can impersonate it and turn
27
+ * tracked data into request headers. It also cannot be serialized:
28
+ * `JSON.stringify` drops symbol keys, so the carrier never reaches OpenPanel
29
+ * as a property even when this transport is not installed.
30
+ */
31
+ export const REQUEST_CONTEXT = Symbol("trakoo.openpanel.requestContext");
32
+ const CLIENT_IP_HEADER = "openpanel-client-ip";
33
+ const USER_AGENT_HEADER = "user-agent";
34
+ const DEFAULT_INITIAL_RETRY_DELAY_MS = 500;
35
+ const DEFAULT_MAX_RETRIES = 3;
36
+ const SUCCESS_STATUSES = new Set([200, 202]);
37
+ const UNAUTHORIZED_STATUS = 401;
38
+ /**
39
+ * Replaces the transport of an OpenPanel client so rejected requests are
40
+ * reported. Returns `false` when the client does not expose the expected
41
+ * transport, in which case the SDK keeps delivering events unchanged.
42
+ */
43
+ export function instrumentOpenPanelDelivery(client, report) {
44
+ const api = openPanelApiOf(client);
45
+ if (!api)
46
+ return false;
47
+ api.fetch = (path, data, options) => deliver(api, path, data, options, report);
48
+ return true;
49
+ }
50
+ /**
51
+ * Wraps a caller-supplied handler so it can never fail an analytics call, and
52
+ * logs the failure when no handler is configured. OpenPanel drops rejected
53
+ * requests silently, so a missing handler must not mean missing signal.
54
+ */
55
+ export function createDeliveryFailureReporter(providerName, onDeliveryFailure) {
56
+ return (failure) => {
57
+ if (!onDeliveryFailure) {
58
+ logDeliveryFailure(providerName, failure);
59
+ return;
60
+ }
61
+ try {
62
+ onDeliveryFailure(failure);
63
+ }
64
+ catch {
65
+ // Delivery reporting must not change the outcome of a tracked event.
66
+ }
67
+ };
68
+ }
69
+ function openPanelApiOf(client) {
70
+ // `Api` is not exported by the SDK, so its instance is matched structurally.
71
+ const api = client?.api;
72
+ if (!api || typeof api !== "object")
73
+ return undefined;
74
+ const candidate = api;
75
+ if (typeof candidate.fetch !== "function")
76
+ return undefined;
77
+ if (typeof candidate.baseUrl !== "string")
78
+ return undefined;
79
+ if (!candidate.headers || typeof candidate.headers !== "object") {
80
+ return undefined;
81
+ }
82
+ return candidate;
83
+ }
84
+ async function deliver(api, path, data, options, report) {
85
+ const url = `${api.baseUrl}${path}`;
86
+ const payloadType = payloadTypeOf(data);
87
+ const headers = requestHeadersOf(data);
88
+ const maxRetries = nonNegativeNumber(api.maxRetries, DEFAULT_MAX_RETRIES);
89
+ const retryDelay = nonNegativeNumber(api.initialRetryDelay, DEFAULT_INITIAL_RETRY_DELAY_MS);
90
+ for (let attempt = 0;; attempt += 1) {
91
+ const outcome = await attemptDelivery(api, url, data, options, headers);
92
+ if (outcome.ok)
93
+ return outcome.body;
94
+ // A rejected key is rejected for every retry, so it is reported at once.
95
+ if (outcome.reason !== "unauthorized" && attempt < maxRetries) {
96
+ await wait(retryDelay * 2 ** attempt);
97
+ continue;
98
+ }
99
+ report({
100
+ attempts: attempt + 1,
101
+ ...(payloadType && { payloadType }),
102
+ reason: outcome.reason,
103
+ ...(outcome.status !== undefined && { status: outcome.status }),
104
+ url,
105
+ });
106
+ return null;
107
+ }
108
+ }
109
+ async function attemptDelivery(api, url, data, options, requestHeaders) {
110
+ try {
111
+ const response = await fetch(url, {
112
+ method: "POST",
113
+ headers: { ...(await resolveHeaders(api.headers)), ...requestHeaders },
114
+ body: data ? JSON.stringify(data) : undefined,
115
+ keepalive: true,
116
+ ...options,
117
+ });
118
+ if (response.status === UNAUTHORIZED_STATUS) {
119
+ return { ok: false, reason: "unauthorized", status: UNAUTHORIZED_STATUS };
120
+ }
121
+ if (!SUCCESS_STATUSES.has(response.status)) {
122
+ return { ok: false, reason: "server_error", status: response.status };
123
+ }
124
+ const text = await response.text();
125
+ return { body: text ? JSON.parse(text) : null, ok: true };
126
+ }
127
+ catch {
128
+ return { ok: false, reason: "network_error" };
129
+ }
130
+ }
131
+ async function resolveHeaders(headers) {
132
+ const resolved = {};
133
+ for (const [key, value] of Object.entries(headers)) {
134
+ const header = await value;
135
+ if (header !== null)
136
+ resolved[key] = header;
137
+ }
138
+ return resolved;
139
+ }
140
+ /**
141
+ * Turns any {@link OpenPanelRequestContext} carried by the payload into this
142
+ * one request's headers. The payload itself is left untouched — the carrier is
143
+ * symbol-keyed, so it is already invisible to `JSON.stringify`.
144
+ */
145
+ function requestHeadersOf(data) {
146
+ const requestContext = propertiesOf(data)?.[REQUEST_CONTEXT];
147
+ if (!requestContext || typeof requestContext !== "object")
148
+ return {};
149
+ const { ip, userAgent } = requestContext;
150
+ return {
151
+ ...(typeof ip === "string" && ip && { [CLIENT_IP_HEADER]: ip }),
152
+ ...(typeof userAgent === "string" &&
153
+ userAgent && { [USER_AGENT_HEADER]: userAgent }),
154
+ };
155
+ }
156
+ function propertiesOf(data) {
157
+ if (!data || typeof data !== "object")
158
+ return undefined;
159
+ const payload = data.payload;
160
+ if (!payload || typeof payload !== "object")
161
+ return undefined;
162
+ const properties = payload.properties;
163
+ if (!properties || typeof properties !== "object")
164
+ return undefined;
165
+ return properties;
166
+ }
167
+ function payloadTypeOf(data) {
168
+ if (!data || typeof data !== "object")
169
+ return undefined;
170
+ const type = data.type;
171
+ return typeof type === "string" ? type : undefined;
172
+ }
173
+ function nonNegativeNumber(value, fallback) {
174
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
175
+ ? value
176
+ : fallback;
177
+ }
178
+ function wait(milliseconds) {
179
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
180
+ }
181
+ function logDeliveryFailure(providerName, failure) {
182
+ const status = failure.status === undefined ? "" : ` (HTTP ${failure.status})`;
183
+ const hint = failure.reason === "unauthorized"
184
+ ? " - check the OpenPanel credentials and the project's allowed origins"
185
+ : "";
186
+ console.error(`[${providerName}] Delivery failed: ${failure.reason}${status} after ${failure.attempts} attempt(s) to ${failure.url}${hint}`);
187
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@trakoo/openpanel",
3
+ "version": "0.0.0",
4
+ "description": "OpenPanel provider for trakoo",
5
+ "type": "module",
6
+ "exports": {
7
+ "./client": {
8
+ "types": "./dist/client.d.ts",
9
+ "import": "./dist/client.js",
10
+ "default": "./dist/client.js"
11
+ },
12
+ "./server": {
13
+ "types": "./dist/server.d.ts",
14
+ "import": "./dist/server.js",
15
+ "default": "./dist/server.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "sideEffects": false,
24
+ "keywords": [
25
+ "analytics",
26
+ "trakoo",
27
+ "openpanel",
28
+ "typescript"
29
+ ],
30
+ "author": "Chris Jayden <https://github.com/multiplehats>",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/multiplehats/trakoo.git",
35
+ "directory": "packages/openpanel"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/multiplehats/trakoo/issues"
39
+ },
40
+ "homepage": "https://trakoo.co/docs/providers/openpanel",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "peerDependencies": {
48
+ "@openpanel/sdk": "^1.3.1",
49
+ "@openpanel/web": "^1.4.1",
50
+ "trakoo": "^1.2.1"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@openpanel/sdk": {
54
+ "optional": true
55
+ },
56
+ "@openpanel/web": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@openpanel/sdk": "^1.3.1",
62
+ "@openpanel/web": "^1.4.1",
63
+ "trakoo": "1.2.1"
64
+ },
65
+ "scripts": {
66
+ "build": "tsc -p tsconfig.build.json",
67
+ "typecheck": "tsc --noEmit",
68
+ "test": "vitest run"
69
+ }
70
+ }