saasco-sdk 0.2.5 → 0.2.6

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/dist/index.js CHANGED
@@ -2,7 +2,187 @@
2
2
  import { isValid, parse } from "psl";
3
3
 
4
4
  // package.json
5
- var version = "0.2.5";
5
+ var version = "0.2.6";
6
+
7
+ // src/lib/deliverOnce.ts
8
+ var sent = /* @__PURE__ */ new Set();
9
+ var pending = /* @__PURE__ */ new Map();
10
+ function deliverOnce(key, send) {
11
+ if (!key || typeof window === "undefined") {
12
+ return Promise.resolve().then(send);
13
+ }
14
+ if (sent.has(key)) {
15
+ return Promise.resolve();
16
+ }
17
+ try {
18
+ if (window.sessionStorage.getItem(key)) {
19
+ return Promise.resolve();
20
+ }
21
+ } catch {
22
+ }
23
+ const existing = pending.get(key);
24
+ if (existing) {
25
+ return existing;
26
+ }
27
+ const task = Promise.resolve().then(send).then(() => {
28
+ sent.add(key);
29
+ try {
30
+ window.sessionStorage.setItem(key, "true");
31
+ } catch {
32
+ }
33
+ }).finally(() => pending.delete(key));
34
+ pending.set(key, task);
35
+ return task;
36
+ }
37
+
38
+ // ../shared/src/lib/analyticsIngestError.ts
39
+ import { z } from "zod";
40
+ var IDENTIFY_INGEST_ERROR_SOURCE = "saasco-identify";
41
+ var ANALYTICS_INGEST_ERROR_SOURCE = "saasco-analytics";
42
+ var AnalyticsIngestErrorCodeSchema = z.enum([
43
+ "CREDENTIAL_INVALID",
44
+ "RESOURCE_FORBIDDEN",
45
+ "RESOURCE_UNSPECIFIED",
46
+ "ENVIRONMENT_UNREACHABLE",
47
+ "INGEST_FAILED"
48
+ ]);
49
+ var AnalyticsIngestSurfaceSchema = z.enum(["identify", "events"]);
50
+ var AnalyticsIngestErrorSchema = z.object({
51
+ affected: z.string(),
52
+ code: AnalyticsIngestErrorCodeSchema,
53
+ message: z.string(),
54
+ projectId: z.string().nullable(),
55
+ recovery: z.string(),
56
+ resource: z.string().nullable(),
57
+ surface: AnalyticsIngestSurfaceSchema
58
+ });
59
+ var IDENTIFY_AFFECTED = "Saasco identification only: CRM contact properties and identity stitching. Page views, charts, and the host application UI are not this failure.";
60
+ var ANALYTICS_EVENT_AFFECTED = "Saasco event tracking only. The host application UI is not this failure.";
61
+ function analyticsIngestError({
62
+ code,
63
+ message,
64
+ projectId,
65
+ resource,
66
+ surface
67
+ }) {
68
+ const copy = ingestCopy({ code, resource, surface });
69
+ return {
70
+ affected: surface === "identify" ? IDENTIFY_AFFECTED : ANALYTICS_EVENT_AFFECTED,
71
+ code,
72
+ message: message ?? copy.message,
73
+ projectId,
74
+ recovery: copy.recovery,
75
+ resource,
76
+ surface
77
+ };
78
+ }
79
+ function ingestCopy({
80
+ code,
81
+ resource,
82
+ surface
83
+ }) {
84
+ const named = resource ? `\`${resource}\`` : "the requested analytics resource";
85
+ const didNotComplete = surface === "identify" ? "Identification did not complete." : "Event tracking did not complete.";
86
+ switch (code) {
87
+ case "CREDENTIAL_INVALID": {
88
+ return {
89
+ message: "Saasco analytics credentials are invalid or missing. This is not an application login or permissions problem.",
90
+ recovery: "Check TINYBIRD_ANALYTICS_TOKEN on the Saasco ingest worker. Do not change host-app auth. Charts and UI styling are unrelated."
91
+ };
92
+ }
93
+ case "RESOURCE_FORBIDDEN": {
94
+ return {
95
+ message: `Saasco analytics is not allowed to use ${named}. ${didNotComplete}`,
96
+ recovery: surface === "identify" ? `Grant the ingest worker access to ${named} (PIPES:READ or DATASOURCES:APPEND). Page tracking may still work.` : `Grant the ingest worker access to ${named} (PIPES:READ or DATASOURCES:APPEND).`
97
+ };
98
+ }
99
+ case "RESOURCE_UNSPECIFIED": {
100
+ return {
101
+ message: `Saasco analytics was denied access without a named pipe or datasource. ${didNotComplete}`,
102
+ recovery: "This is a Saasco ingest configuration issue: the worker could not name the Tinybird resource it tried to use. Host-app login is unrelated."
103
+ };
104
+ }
105
+ case "ENVIRONMENT_UNREACHABLE": {
106
+ return {
107
+ message: surface === "identify" ? "The Saasco ingest host could not be reached, so identification was not sent." : "The Saasco ingest host could not be reached, so this event was not sent.",
108
+ recovery: "If `proxy` points at localhost, start the analytics worker (`pnpm worker:dev`) or use https://t.saasco.com/. This does not mean the host app is broken."
109
+ };
110
+ }
111
+ default: {
112
+ return {
113
+ message: surface === "identify" ? "Saasco identification could not be completed." : "Saasco event tracking could not be completed.",
114
+ recovery: "Retry. If it keeps failing, this is a Saasco analytics ingest issue, not host-app login or chart configuration."
115
+ };
116
+ }
117
+ }
118
+ }
119
+ function ingestErrorSource(surface) {
120
+ return surface === "identify" ? IDENTIFY_INGEST_ERROR_SOURCE : ANALYTICS_INGEST_ERROR_SOURCE;
121
+ }
122
+ function redactSecrets(text) {
123
+ return text.replaceAll(/Bearer\s+\S+/giu, "Bearer [redacted]").replaceAll(/\bp\.[A-Za-z0-9._-]+/gu, "[redacted]").replaceAll(/token=[^&\s]+/giu, "token=[redacted]");
124
+ }
125
+ function isUnsafeIngestMessage(text) {
126
+ return /Bearer \[redacted\]|token=\[redacted\]/iu.test(text) || /token you have provided|doesn't have access to this resource|not enough permissions|invalid token/iu.test(
127
+ text
128
+ );
129
+ }
130
+
131
+ // src/lib/formatAnalyticsRequestFailure.ts
132
+ function formatAnalyticsRequestFailure(input) {
133
+ const surface = input.path === "identify" ? "identify" : "events";
134
+ const projectId = input.projectId ?? null;
135
+ if (input.reason === "http") {
136
+ const parsed = AnalyticsIngestErrorSchema.safeParse(input.body);
137
+ if (parsed.success) {
138
+ return parsed.data;
139
+ }
140
+ }
141
+ if (input.reason === "unreachable") {
142
+ return analyticsIngestError({
143
+ code: "ENVIRONMENT_UNREACHABLE",
144
+ projectId,
145
+ resource: null,
146
+ surface
147
+ });
148
+ }
149
+ const legacy = input.body && typeof input.body === "object" && !Array.isArray(input.body) && "message" in input.body && typeof input.body.message === "string" ? input.body.message : "";
150
+ const safeMessage = redactSecrets(legacy).trim();
151
+ return analyticsIngestError({
152
+ code: "INGEST_FAILED",
153
+ message: safeMessage && !isUnsafeIngestMessage(safeMessage) ? safeMessage : void 0,
154
+ projectId,
155
+ resource: null,
156
+ surface
157
+ });
158
+ }
159
+ function reportIngestFailure({
160
+ failure,
161
+ logger,
162
+ path,
163
+ seen
164
+ }) {
165
+ const key = `${path}:${failure.code}:${failure.resource ?? ""}`;
166
+ if (!seen.has(key)) {
167
+ seen.add(key);
168
+ logger.warn(...analyticsFailureLogLines(failure, path));
169
+ }
170
+ return {
171
+ success: false,
172
+ message: failure.message
173
+ };
174
+ }
175
+ function analyticsFailureLogLines(failure, path) {
176
+ const lines = [
177
+ `${ingestErrorSource(failure.surface)} ${path} failed (${failure.code})`,
178
+ failure.message,
179
+ failure.resource ? `Resource: ${failure.resource}` : null,
180
+ failure.projectId ? `Project: ${failure.projectId}` : null,
181
+ `Affected: ${failure.affected}`,
182
+ `Recovery: ${failure.recovery}`
183
+ ];
184
+ return lines.filter((line) => line !== null);
185
+ }
6
186
 
7
187
  // src/lib/timezones.ts
8
188
  var timezones = {
@@ -482,12 +662,10 @@ var AnalyticsLogger = class {
482
662
  );
483
663
  }
484
664
  /**
485
- * Log warning information
665
+ * Warnings always print. Ingest failures use this so they stay visible when
666
+ * the default log level is ERROR, without `console.error` (Next.js overlays it).
486
667
  */
487
668
  warn(...args) {
488
- if (this.config.level < 1 /* WARN */) {
489
- return;
490
- }
491
669
  const icon = "\u25CD";
492
670
  const message = `${icon} ${this.config.label}`;
493
671
  console.warn(
@@ -633,6 +811,50 @@ function resolveAdParams(params) {
633
811
 
634
812
  // src/lib/integrations/facebook-pixel.ts
635
813
  import { sha256 } from "js-sha256";
814
+
815
+ // src/lib/integrations/loadPixelScript.ts
816
+ var loads = /* @__PURE__ */ new Map();
817
+ function loadPixelScript(src) {
818
+ const existingLoad = loads.get(src);
819
+ if (existingLoad) {
820
+ return existingLoad;
821
+ }
822
+ const existingScript = Array.from(document.scripts).find(
823
+ (script2) => script2.src === src
824
+ );
825
+ const script = existingScript ?? document.createElement("script");
826
+ const task = new Promise((resolve, reject) => {
827
+ const cleanup = () => {
828
+ clearTimeout(timeout);
829
+ script.removeEventListener("load", loaded);
830
+ script.removeEventListener("error", failed);
831
+ };
832
+ const loaded = () => {
833
+ cleanup();
834
+ resolve();
835
+ };
836
+ const failed = () => {
837
+ cleanup();
838
+ script.remove();
839
+ reject(new Error(`Pixel script unavailable: ${src}`));
840
+ };
841
+ const timeout = setTimeout(failed, 1e4);
842
+ script.addEventListener("load", loaded);
843
+ script.addEventListener("error", failed);
844
+ if (!existingScript) {
845
+ script.async = true;
846
+ script.src = src;
847
+ document.head.append(script);
848
+ }
849
+ }).catch((error) => {
850
+ loads.delete(src);
851
+ throw error;
852
+ });
853
+ loads.set(src, task);
854
+ return task;
855
+ }
856
+
857
+ // src/lib/integrations/facebook-pixel.ts
636
858
  var isBrowser2 = typeof window !== "undefined";
637
859
  var isServer2 = !isBrowser2;
638
860
  var standardFacebookEvents = [
@@ -674,70 +896,32 @@ function createFacebookPixelIntegration(config, debug) {
674
896
  return;
675
897
  }
676
898
  logger.info("Initializing Facebook Pixel", { pixelId: config.pixelId });
677
- if (window.fbq || window._fbq) {
678
- logger.warn("Facebook Pixel is already initialized");
679
- isPixelReady = true;
680
- return;
681
- }
682
- return new Promise((resolve, reject) => {
683
- try {
684
- (function init(f, b, e, v, n, t, s) {
685
- if (f.fbq) {
686
- return;
687
- }
688
- n = f.fbq = function fbq2() {
689
- n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
690
- };
691
- if (!f._fbq) {
692
- f._fbq = n;
693
- }
694
- n.push = n;
695
- n.loaded = true;
696
- n.version = "2.0";
697
- n.queue = [];
698
- t = b.createElement(e);
699
- t.async = true;
700
- t.src = v;
701
- t.addEventListener("load", () => {
702
- isPixelReady = true;
703
- logger.info("Facebook Pixel script loaded successfully");
704
- resolve();
705
- });
706
- t.addEventListener("error", () => {
707
- logger.error("Failed to load Facebook Pixel script");
708
- reject(new Error("Failed to load Facebook Pixel script"));
709
- });
710
- s = b.getElementsByTagName(e)[0];
711
- s.parentNode.insertBefore(t, s);
712
- })(
713
- window,
714
- document,
715
- "script",
716
- "https://connect.facebook.net/en_US/fbevents.js"
717
- );
718
- const { fbq } = window;
719
- if (!fbq) {
720
- reject(new Error("Facebook Pixel failed to initialize"));
721
- return;
722
- }
723
- fbq.disablePushState = true;
724
- fbq.allowDuplicatePageViews = true;
725
- if (config.automaticConfiguration === false) {
726
- logger.debug("Disabling automatic configuration");
727
- fbq("set", "autoConfig", false, config.pixelId);
899
+ if (!window.fbq) {
900
+ const fbq2 = (...args) => {
901
+ if (fbq2.callMethod) {
902
+ fbq2.callMethod(...args);
903
+ } else {
904
+ fbq2.queue.push(args);
728
905
  }
729
- logger.debug("Initializing Facebook Pixel with ID", config.pixelId);
730
- fbq("init", config.pixelId);
731
- if (typeof fbq === "function") {
732
- isPixelReady = true;
733
- logger.info("Facebook Pixel initialized synchronously");
734
- resolve();
735
- }
736
- } catch (error) {
737
- logger.error("Error during Facebook Pixel initialization", error);
738
- reject(error);
739
- }
740
- });
906
+ };
907
+ fbq2.queue = [];
908
+ fbq2.push = fbq2;
909
+ fbq2.loaded = true;
910
+ fbq2.version = "2.0";
911
+ window.fbq = fbq2;
912
+ window._fbq = fbq2;
913
+ }
914
+ const { fbq } = window;
915
+ fbq.disablePushState = true;
916
+ fbq.allowDuplicatePageViews = true;
917
+ if (config.automaticConfiguration === false) {
918
+ fbq("set", "autoConfig", false, config.pixelId);
919
+ }
920
+ fbq("init", config.pixelId);
921
+ if (!fbq.callMethod) {
922
+ await loadPixelScript("https://connect.facebook.net/en_US/fbevents.js");
923
+ }
924
+ isPixelReady = true;
741
925
  },
742
926
  track: (eventName, properties, _context) => {
743
927
  const { fbq } = window;
@@ -745,9 +929,11 @@ function createFacebookPixelIntegration(config, debug) {
745
929
  logger.warn("Facebook Pixel not ready, skipping track event", {
746
930
  eventName
747
931
  });
748
- return;
932
+ throw new Error("Facebook Pixel not ready");
749
933
  }
750
934
  const fbEventName = getFacebookEventName(eventName, config.eventMapping);
935
+ const eventId = properties?.["event_id"];
936
+ const eventOptions = typeof eventId === "string" && eventId.trim().length > 0 ? { eventID: eventId } : void 0;
751
937
  const eventsWithParams = ["Purchase", "StartTrial", "Subscribe"];
752
938
  const isStandardEvent = standardFacebookEvents.includes(
753
939
  fbEventName
@@ -775,10 +961,18 @@ function createFacebookPixelIntegration(config, debug) {
775
961
  event: fbEventName,
776
962
  params: fbParams
777
963
  });
778
- fbq(trackType, fbEventName, fbParams);
964
+ if (eventOptions) {
965
+ fbq(trackType, fbEventName, fbParams, eventOptions);
966
+ } else {
967
+ fbq(trackType, fbEventName, fbParams);
968
+ }
779
969
  } else {
780
970
  logger.info("Tracking Facebook Pixel event", { event: fbEventName });
781
- fbq(trackType, fbEventName);
971
+ if (eventOptions) {
972
+ fbq(trackType, fbEventName, {}, eventOptions);
973
+ } else {
974
+ fbq(trackType, fbEventName);
975
+ }
782
976
  }
783
977
  },
784
978
  identify: (userId, properties, _context) => {
@@ -1132,12 +1326,12 @@ var IntegrationManager = class {
1132
1326
  globalQueue = [];
1133
1327
  config;
1134
1328
  logger;
1135
- initTime;
1136
1329
  currentEnvironment;
1137
1330
  flushTimer;
1138
1331
  unloadHandler;
1139
1332
  constructor(config = {}) {
1140
1333
  this.config = {
1334
+ deduplicationNamespace: config.deduplicationNamespace ?? "default",
1141
1335
  flushInterval: config.flushInterval ?? 5e3,
1142
1336
  loggerLevel: config.loggerLevel ?? 0 /* ERROR */,
1143
1337
  maxIntegrationWaitTime: config.maxIntegrationWaitTime ?? 1e4,
@@ -1147,7 +1341,6 @@ var IntegrationManager = class {
1147
1341
  label: "Saasco Integrations Debug",
1148
1342
  level: this.config.loggerLevel
1149
1343
  });
1150
- this.initTime = Date.now();
1151
1344
  this.currentEnvironment = typeof window === "undefined" ? "server" : "client";
1152
1345
  this.setupPeriodicFlushing();
1153
1346
  this.setupUnloadHandler();
@@ -1224,6 +1417,8 @@ var IntegrationManager = class {
1224
1417
  */
1225
1418
  send(partial) {
1226
1419
  const envelope = {
1420
+ delivered: /* @__PURE__ */ new Set(),
1421
+ pending: /* @__PURE__ */ new Set(),
1227
1422
  context: { ...this.context },
1228
1423
  id: uuid(),
1229
1424
  name: partial.name,
@@ -1231,65 +1426,55 @@ var IntegrationManager = class {
1231
1426
  timestamp: Date.now(),
1232
1427
  type: partial.type
1233
1428
  };
1234
- if (!this.isReady()) {
1235
- if (this.globalQueue.length >= this.config.maxQueueSize) {
1236
- this.globalQueue.shift();
1237
- this.logger.warn("Queue full \u2192 dropped oldest");
1238
- }
1239
- this.globalQueue.push(envelope);
1240
- this.logger.debug(`Queued (${this.globalQueue.length})`, envelope);
1241
- return;
1429
+ if (this.globalQueue.length >= this.config.maxQueueSize) {
1430
+ this.globalQueue.shift();
1431
+ this.logger.warn("Queue full \u2192 dropped oldest");
1242
1432
  }
1243
- this.deliver(envelope);
1433
+ this.globalQueue.push(envelope);
1434
+ this.flush();
1244
1435
  }
1245
- async deliver(evt) {
1246
- for (const { integration, status } of this.integrations.values()) {
1247
- if (status !== "ready") {
1248
- continue;
1249
- }
1250
- try {
1251
- if (evt.type === "track" && integration.track && evt.name) {
1252
- this.logger.debug(`Track (${integration.name}) ${evt.name}`, {
1253
- context: evt.context,
1254
- properties: evt.properties
1255
- });
1256
- await integration.track(evt.name, evt.properties, evt.context);
1257
- } else if (evt.type === "identify" && integration.identify) {
1258
- this.logger.debug(`Identify (${integration.name})`, {
1259
- context: evt.context,
1260
- properties: evt.properties,
1261
- userId: evt.context.distinctId
1262
- });
1263
- await integration.identify(
1264
- evt.context.distinctId,
1265
- evt.properties,
1266
- evt.context
1267
- );
1268
- }
1269
- } catch (error) {
1270
- this.logger.error(
1271
- `deliver error in ${integration.name} (${evt.type})`,
1272
- error
1436
+ async deliver(evt, state) {
1437
+ const { integration } = state;
1438
+ evt.pending.add(integration.name);
1439
+ try {
1440
+ if (evt.type === "track" && integration.track && evt.name) {
1441
+ const eventId = evt.properties?.["event_id"];
1442
+ const key = typeof eventId === "string" && eventId.trim() ? `saasco:${this.config.deduplicationNamespace}:${integration.name}:${evt.name}:${eventId}` : void 0;
1443
+ const { track } = integration;
1444
+ const { name } = evt;
1445
+ await deliverOnce(
1446
+ key,
1447
+ () => track.call(integration, name, evt.properties, evt.context)
1448
+ );
1449
+ } else if (evt.type === "identify" && integration.identify) {
1450
+ await integration.identify(
1451
+ evt.context.distinctId,
1452
+ evt.properties,
1453
+ evt.context
1273
1454
  );
1274
1455
  }
1456
+ evt.delivered.add(integration.name);
1457
+ } catch (error) {
1458
+ this.logger.error(
1459
+ `deliver error in ${integration.name} (${evt.type})`,
1460
+ error
1461
+ );
1462
+ } finally {
1463
+ evt.pending.delete(integration.name);
1275
1464
  }
1276
1465
  }
1277
- /**
1278
- * Flush queued events FIFO once at least one integration is ready.
1279
- */
1466
+ /** Each ready destination drains its own copy; a slow/blocked pixel cannot
1467
+ * discard another pixel's queued events or hold up the ready destinations. */
1280
1468
  flush() {
1281
- if (this.globalQueue.length === 0) {
1282
- return;
1283
- }
1284
- if (!this.isReady()) {
1285
- return;
1286
- }
1287
- this.logger.debug(`flushing ${this.globalQueue.length} queued events`);
1288
- const toSend = this.globalQueue;
1289
- this.globalQueue = [];
1290
- for (const evt of toSend) {
1291
- this.deliver(evt);
1292
- }
1469
+ this.globalQueue = this.globalQueue.filter((evt) => {
1470
+ for (const state of this.integrations.values()) {
1471
+ const { name } = state.integration;
1472
+ if (state.status === "ready" && !evt.delivered.has(name) && !evt.pending.has(name)) {
1473
+ void this.deliver(evt, state);
1474
+ }
1475
+ }
1476
+ return evt.delivered.size < this.integrations.size && Date.now() - evt.timestamp < this.config.maxIntegrationWaitTime;
1477
+ });
1293
1478
  }
1294
1479
  readyCount() {
1295
1480
  let n = 0;
@@ -1300,18 +1485,6 @@ var IntegrationManager = class {
1300
1485
  }
1301
1486
  return n;
1302
1487
  }
1303
- isReady() {
1304
- const timeElapsed = Date.now() - this.initTime;
1305
- if (this.readyCount() < this.integrations.size && timeElapsed < this.config.maxIntegrationWaitTime) {
1306
- return false;
1307
- }
1308
- if (this.readyCount() < this.integrations.size) {
1309
- this.logger.info(
1310
- `proceeding with ${this.readyCount()}/${this.integrations.size} integrations ready after ${timeElapsed}ms wait`
1311
- );
1312
- }
1313
- return true;
1314
- }
1315
1488
  /**
1316
1489
  * Setup periodic flushing if enabled
1317
1490
  */
@@ -1395,50 +1568,20 @@ function createPinterestTagIntegration(config, debug) {
1395
1568
  return;
1396
1569
  }
1397
1570
  logger.info("Initializing Pinterest Tag", { tagId: config.tagId });
1398
- if (window.pintrk || window._pintrk) {
1399
- logger.warn("Pinterest Tag is already initialized");
1400
- isTagReady = true;
1401
- return;
1571
+ if (!window.pintrk) {
1572
+ const pintrk = (...args) => pintrk.queue.push(args);
1573
+ pintrk.queue = [];
1574
+ pintrk.version = "3.0";
1575
+ window.pintrk = pintrk;
1402
1576
  }
1403
- return new Promise((resolve, reject) => {
1404
- try {
1405
- (function init(e) {
1406
- if (!window.pintrk) {
1407
- window.pintrk = function pintrk(...args) {
1408
- window.pintrk.queue.push(args);
1409
- };
1410
- const n = window.pintrk;
1411
- n.queue = [];
1412
- n.version = "3.0";
1413
- const t = document.createElement("script");
1414
- t.async = true;
1415
- t.src = e;
1416
- t.addEventListener("load", () => {
1417
- window.pintrk("load", config.tagId);
1418
- window.pintrk("page");
1419
- setTimeout(() => {
1420
- logger.debug("Pinterest Tag initialized");
1421
- isTagReady = true;
1422
- resolve();
1423
- }, 500);
1424
- });
1425
- t.addEventListener("error", () => {
1426
- logger.error("Failed to load Pinterest Tag script");
1427
- reject(new Error("Failed to load Pinterest Tag script"));
1428
- });
1429
- const r = document.getElementsByTagName("script")[0];
1430
- r.parentNode.insertBefore(t, r);
1431
- }
1432
- })("https://s.pinimg.com/ct/core.js");
1433
- } catch (error) {
1434
- logger.error("Failed to initialize Pinterest Tag", error);
1435
- reject(error);
1436
- }
1437
- });
1577
+ await loadPixelScript("https://s.pinimg.com/ct/core.js");
1578
+ window.pintrk("load", config.tagId);
1579
+ window.pintrk("page");
1580
+ isTagReady = true;
1438
1581
  },
1439
1582
  track: (eventName, properties) => {
1440
1583
  if (!isTagReady || !window.pintrk) {
1441
- return;
1584
+ throw new Error("Pinterest Tag not ready");
1442
1585
  }
1443
1586
  const pinterestEventName = getPinterestEventName(
1444
1587
  eventName,
@@ -1567,8 +1710,8 @@ function createTikTokPixelIntegration(config, debug) {
1567
1710
  return;
1568
1711
  }
1569
1712
  logger.info("Initializing TikTok Pixel", { pixelId: config.pixelId });
1570
- if (window.ttq) {
1571
- logger.warn("TikTok Pixel is already initialized");
1713
+ if (window.ttq?.ready) {
1714
+ await new Promise((resolve) => window.ttq.ready(resolve));
1572
1715
  isPixelReady = true;
1573
1716
  return;
1574
1717
  }
@@ -1620,28 +1763,15 @@ function createTikTokPixelIntegration(config, debug) {
1620
1763
  ttq._t[e] = Date.now();
1621
1764
  ttq._o ||= {};
1622
1765
  ttq._o[e] = n || {};
1623
- const script = document.createElement("script");
1624
- script.type = "text/javascript";
1625
- script.async = true;
1626
- script.src = `${r}?sdkid=${e}&lib=${t}`;
1627
- script.addEventListener("load", () => {
1766
+ loadPixelScript(`${r}?sdkid=${e}&lib=${t}`).then(() => {
1628
1767
  isPixelReady = true;
1629
1768
  resolve();
1630
- });
1631
- script.addEventListener("error", () => {
1632
- reject(new Error("Failed to load TikTok Pixel script"));
1633
- });
1634
- const firstScript = document.getElementsByTagName("script")[0];
1635
- firstScript.parentNode.insertBefore(script, firstScript);
1769
+ }).catch(reject);
1636
1770
  };
1637
1771
  ttq.load(
1638
1772
  config.pixelId,
1639
1773
  config.testMode ? { test_mode: true } : void 0
1640
1774
  );
1641
- if (window.ttq && typeof window.ttq === "function") {
1642
- isPixelReady = true;
1643
- resolve();
1644
- }
1645
1775
  })(window, document, "ttq");
1646
1776
  } catch (error) {
1647
1777
  reject(error);
@@ -1650,8 +1780,8 @@ function createTikTokPixelIntegration(config, debug) {
1650
1780
  },
1651
1781
  track: (eventName, properties, _context) => {
1652
1782
  const { ttq } = window;
1653
- if (!isPixelReady || !ttq) {
1654
- return;
1783
+ if (!isPixelReady || !ttq?.track) {
1784
+ throw new Error("TikTok Pixel not ready");
1655
1785
  }
1656
1786
  const ttEventName = getTikTokEventName(eventName, config.eventMapping);
1657
1787
  if (ttEventName === "PageView") {
@@ -1659,7 +1789,10 @@ function createTikTokPixelIntegration(config, debug) {
1659
1789
  return;
1660
1790
  }
1661
1791
  const ttParams = mapTikTokEventParameters(ttEventName, properties);
1662
- if (ttParams && Object.keys(ttParams).length > 0) {
1792
+ const eventId = properties?.["event_id"];
1793
+ if (typeof eventId === "string" && eventId.trim().length > 0) {
1794
+ ttq.track?.(ttEventName, ttParams ?? {}, { event_id: eventId });
1795
+ } else if (ttParams && Object.keys(ttParams).length > 0) {
1663
1796
  ttq.track?.(ttEventName, ttParams);
1664
1797
  } else {
1665
1798
  ttq.track?.(ttEventName);
@@ -2179,6 +2312,15 @@ function migrateFromLocalStorage() {
2179
2312
  if (isServer5) {
2180
2313
  return;
2181
2314
  }
2315
+ let storage;
2316
+ try {
2317
+ storage = globalThis.localStorage ?? null;
2318
+ } catch {
2319
+ return;
2320
+ }
2321
+ if (!storage) {
2322
+ return;
2323
+ }
2182
2324
  const keysToMigrate = [
2183
2325
  `${PREF}-session-id`,
2184
2326
  `${PREF}-anonymous-id`,
@@ -2188,20 +2330,23 @@ function migrateFromLocalStorage() {
2188
2330
  ];
2189
2331
  for (const key of keysToMigrate) {
2190
2332
  try {
2191
- const value = localStorage.getItem(key);
2333
+ const value = storage.getItem(key);
2192
2334
  if (value) {
2193
2335
  const item = JSON.parse(value);
2194
2336
  const now = Date.now();
2195
2337
  if (item.expiry > now) {
2196
2338
  const remainingTtl = item.expiry - now;
2197
2339
  setCookie(key, JSON.stringify(item.value), remainingTtl);
2198
- localStorage.removeItem(key);
2340
+ storage.removeItem(key);
2199
2341
  } else {
2200
- localStorage.removeItem(key);
2342
+ storage.removeItem(key);
2201
2343
  }
2202
2344
  }
2203
2345
  } catch {
2204
- localStorage.removeItem(key);
2346
+ try {
2347
+ storage.removeItem(key);
2348
+ } catch {
2349
+ }
2205
2350
  }
2206
2351
  }
2207
2352
  }
@@ -2432,7 +2577,8 @@ var Saasco = class {
2432
2577
  };
2433
2578
  this.integrationManager = new IntegrationManager({
2434
2579
  loggerLevel,
2435
- maxQueueSize: 1e3
2580
+ maxQueueSize: 1e3,
2581
+ deduplicationNamespace: this.config.projectId
2436
2582
  });
2437
2583
  this.integrationManager.setContext({});
2438
2584
  void (async () => {
@@ -2455,6 +2601,7 @@ var Saasco = class {
2455
2601
  // here so an `identify()` that fires before the widget bundle finishes
2456
2602
  // loading is replayed once it does.
2457
2603
  lastSupportIdentity = null;
2604
+ ingestFailureKeys = /* @__PURE__ */ new Set();
2458
2605
  init() {
2459
2606
  if (this.isInitialized) {
2460
2607
  this.logger.info(
@@ -2506,48 +2653,52 @@ var Saasco = class {
2506
2653
  );
2507
2654
  return;
2508
2655
  }
2509
- for (const integrationConfig of this.config.integrations) {
2510
- try {
2511
- if (integrationConfig.type === "facebook-pixel") {
2512
- this.logger.info(
2513
- "Registering Facebook Pixel integration with ID:",
2514
- integrationConfig.config.pixelId
2515
- );
2516
- const fbIntegration = createFacebookPixelIntegration(
2517
- integrationConfig.config,
2518
- integrationConfig.debug
2519
- );
2520
- await this.integrationManager.registerIntegration(fbIntegration);
2521
- } else if (integrationConfig.type === "pinterest-tag") {
2522
- this.logger.info(
2523
- "Registering Pinterest Tag integration with ID:",
2524
- integrationConfig.config.tagId
2525
- );
2526
- const pinterestIntegration = createPinterestTagIntegration(
2527
- integrationConfig.config,
2528
- integrationConfig.debug
2529
- );
2530
- await this.integrationManager.registerIntegration(
2531
- pinterestIntegration
2532
- );
2533
- } else if (integrationConfig.type === "tiktok-pixel") {
2656
+ await Promise.all(
2657
+ this.config.integrations.map(async (integrationConfig) => {
2658
+ try {
2659
+ if (integrationConfig.type === "facebook-pixel") {
2660
+ this.logger.info(
2661
+ "Registering Facebook Pixel integration with ID:",
2662
+ integrationConfig.config.pixelId
2663
+ );
2664
+ const fbIntegration = createFacebookPixelIntegration(
2665
+ integrationConfig.config,
2666
+ integrationConfig.debug
2667
+ );
2668
+ await this.integrationManager.registerIntegration(fbIntegration);
2669
+ } else if (integrationConfig.type === "pinterest-tag") {
2670
+ this.logger.info(
2671
+ "Registering Pinterest Tag integration with ID:",
2672
+ integrationConfig.config.tagId
2673
+ );
2674
+ const pinterestIntegration = createPinterestTagIntegration(
2675
+ integrationConfig.config,
2676
+ integrationConfig.debug
2677
+ );
2678
+ await this.integrationManager.registerIntegration(
2679
+ pinterestIntegration
2680
+ );
2681
+ } else if (integrationConfig.type === "tiktok-pixel") {
2682
+ this.logger.info(
2683
+ "Registering TikTok Pixel integration with ID:",
2684
+ integrationConfig.config.pixelId
2685
+ );
2686
+ const tiktokIntegration = createTikTokPixelIntegration(
2687
+ integrationConfig.config,
2688
+ integrationConfig.debug
2689
+ );
2690
+ await this.integrationManager.registerIntegration(
2691
+ tiktokIntegration
2692
+ );
2693
+ }
2694
+ } catch (error) {
2534
2695
  this.logger.info(
2535
- "Registering TikTok Pixel integration with ID:",
2536
- integrationConfig.config.pixelId
2537
- );
2538
- const tiktokIntegration = createTikTokPixelIntegration(
2539
- integrationConfig.config,
2540
- integrationConfig.debug
2696
+ `Failed to register ${integrationConfig.type} integration:`,
2697
+ error
2541
2698
  );
2542
- await this.integrationManager.registerIntegration(tiktokIntegration);
2543
2699
  }
2544
- } catch (error) {
2545
- this.logger.info(
2546
- `Failed to register ${integrationConfig.type} integration:`,
2547
- error
2548
- );
2549
- }
2550
- }
2700
+ })
2701
+ );
2551
2702
  }
2552
2703
  track(actionOrPayload, propertiesOrNothing, contextOrNothing) {
2553
2704
  if (!this.config.projectId) {
@@ -2604,7 +2755,19 @@ var Saasco = class {
2604
2755
  });
2605
2756
  }
2606
2757
  this.integrationManager.track(action, properties);
2607
- return this.doRequest("events", requestData);
2758
+ const eventId = properties?.["event_id"];
2759
+ if (!isBrowser5 || typeof eventId !== "string" || !eventId.trim()) {
2760
+ return this.doRequest("events", requestData);
2761
+ }
2762
+ return deliverOnce(
2763
+ `saasco:${this.config.projectId}:ingest:${action}:${eventId}`,
2764
+ async () => {
2765
+ const result = await this.doRequest("events", requestData);
2766
+ if (!result.success) {
2767
+ throw new Error(result.message);
2768
+ }
2769
+ }
2770
+ ).then(() => ({ success: true, message: "Event dispatched" })).catch((error) => ({ success: false, message: String(error) }));
2608
2771
  }
2609
2772
  /**
2610
2773
  * The page method lets you record page views on your website
@@ -2704,7 +2867,10 @@ var Saasco = class {
2704
2867
  },
2705
2868
  context: context || {}
2706
2869
  };
2707
- this.logger.info("identify", requestData);
2870
+ this.logger.info("identify", {
2871
+ distinctId,
2872
+ projectId: this.config.projectId
2873
+ });
2708
2874
  if (!this.config.enabled) {
2709
2875
  return Promise.resolve({
2710
2876
  success: true,
@@ -2740,6 +2906,13 @@ var Saasco = class {
2740
2906
  message: "Analytics is disabled"
2741
2907
  };
2742
2908
  }
2909
+ const projectId = requestData.projectId ?? null;
2910
+ const fail = (input) => reportIngestFailure({
2911
+ failure: formatAnalyticsRequestFailure({ path, projectId, ...input }),
2912
+ logger: this.logger,
2913
+ path,
2914
+ seen: this.ingestFailureKeys
2915
+ });
2743
2916
  let response;
2744
2917
  try {
2745
2918
  response = await fetch(url, {
@@ -2749,46 +2922,22 @@ var Saasco = class {
2749
2922
  },
2750
2923
  body: JSON.stringify(requestData)
2751
2924
  });
2752
- } catch (error) {
2753
- const message = error instanceof Error ? error.message : String(error);
2754
- this.logger.warn(
2755
- `Network request failed`,
2756
- `
2757
- Error Message: "${message}"`,
2758
- `
2759
- Url: "${url}"`
2760
- );
2761
- return {
2762
- success: false,
2763
- message
2764
- };
2925
+ } catch {
2926
+ return fail({ reason: "unreachable" });
2765
2927
  }
2928
+ let responseBody;
2766
2929
  try {
2767
- const responseBody = await response.json();
2768
- if (!response.ok) {
2769
- throw new Error(
2770
- `${responseBody.message}: ${responseBody.errors ? `[${responseBody.errors.join(", ")}]` : ""}` || JSON.stringify(responseBody)
2771
- );
2772
- }
2773
- return {
2774
- success: true,
2775
- message: responseBody.message ?? ""
2776
- };
2777
- } catch (error) {
2778
- const message = error instanceof Error ? error.message : String(error);
2779
- this.logger.error(
2780
- `
2781
- Error Message: "${message}"`,
2782
- `
2783
- Path: "/${path}"`,
2784
- `
2785
- Request Data: ${JSON.stringify(requestData, null, 2)}`
2786
- );
2787
- return {
2788
- success: false,
2789
- message
2790
- };
2930
+ responseBody = await response.json();
2931
+ } catch {
2932
+ return fail({ reason: "http" });
2933
+ }
2934
+ if (!response.ok) {
2935
+ return fail({ reason: "http", body: responseBody });
2791
2936
  }
2937
+ return {
2938
+ success: true,
2939
+ message: responseBody && typeof responseBody === "object" && "message" in responseBody && typeof responseBody.message === "string" ? responseBody.message : ""
2940
+ };
2792
2941
  }
2793
2942
  /**
2794
2943
  * If autoPageTracking is enabled, this will automatically track page views
@@ -3016,31 +3165,31 @@ function getLoggerLevel(config) {
3016
3165
  }
3017
3166
 
3018
3167
  // ../shared/src/lib/types/browserContext.ts
3019
- import { z } from "zod";
3020
- var BrowserContextSchema = z.object({
3021
- $href: z.string(),
3022
- $locale: z.string(),
3023
- $location: z.string(),
3024
- $pathname: z.string(),
3025
- $referrer: z.string(),
3026
- $referringDomain: z.string(),
3027
- $screenDPI: z.number(),
3028
- $screenHeight: z.number(),
3029
- $screenWidth: z.number(),
3030
- $title: z.string(),
3031
- $userAgent: z.string(),
3032
- $utmAdId: z.string().nullable(),
3033
- $utmAdSource: z.string().nullable(),
3034
- $utmCampaign: z.string().nullable(),
3035
- $utmCampaignId: z.string().nullable(),
3036
- $utmContent: z.string().nullable(),
3037
- $utmCreativeFormat: z.string().nullable(),
3038
- $utmId: z.string().nullable(),
3039
- $utmMarketingTactic: z.string().nullable(),
3040
- $utmMedium: z.string().nullable(),
3041
- $utmSource: z.string().nullable(),
3042
- $utmSourcePlatform: z.string().nullable(),
3043
- $utmTerm: z.string().nullable()
3168
+ import { z as z2 } from "zod";
3169
+ var BrowserContextSchema = z2.object({
3170
+ $href: z2.string(),
3171
+ $locale: z2.string(),
3172
+ $location: z2.string(),
3173
+ $pathname: z2.string(),
3174
+ $referrer: z2.string(),
3175
+ $referringDomain: z2.string(),
3176
+ $screenDPI: z2.number(),
3177
+ $screenHeight: z2.number(),
3178
+ $screenWidth: z2.number(),
3179
+ $title: z2.string(),
3180
+ $userAgent: z2.string(),
3181
+ $utmAdId: z2.string().nullable(),
3182
+ $utmAdSource: z2.string().nullable(),
3183
+ $utmCampaign: z2.string().nullable(),
3184
+ $utmCampaignId: z2.string().nullable(),
3185
+ $utmContent: z2.string().nullable(),
3186
+ $utmCreativeFormat: z2.string().nullable(),
3187
+ $utmId: z2.string().nullable(),
3188
+ $utmMarketingTactic: z2.string().nullable(),
3189
+ $utmMedium: z2.string().nullable(),
3190
+ $utmSource: z2.string().nullable(),
3191
+ $utmSourcePlatform: z2.string().nullable(),
3192
+ $utmTerm: z2.string().nullable()
3044
3193
  });
3045
3194
  export {
3046
3195
  BrowserContextSchema,