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.cjs CHANGED
@@ -38,7 +38,187 @@ module.exports = __toCommonJS(index_exports);
38
38
  var import_psl = require("psl");
39
39
 
40
40
  // package.json
41
- var version = "0.2.5";
41
+ var version = "0.2.6";
42
+
43
+ // src/lib/deliverOnce.ts
44
+ var sent = /* @__PURE__ */ new Set();
45
+ var pending = /* @__PURE__ */ new Map();
46
+ function deliverOnce(key, send) {
47
+ if (!key || typeof window === "undefined") {
48
+ return Promise.resolve().then(send);
49
+ }
50
+ if (sent.has(key)) {
51
+ return Promise.resolve();
52
+ }
53
+ try {
54
+ if (window.sessionStorage.getItem(key)) {
55
+ return Promise.resolve();
56
+ }
57
+ } catch {
58
+ }
59
+ const existing = pending.get(key);
60
+ if (existing) {
61
+ return existing;
62
+ }
63
+ const task = Promise.resolve().then(send).then(() => {
64
+ sent.add(key);
65
+ try {
66
+ window.sessionStorage.setItem(key, "true");
67
+ } catch {
68
+ }
69
+ }).finally(() => pending.delete(key));
70
+ pending.set(key, task);
71
+ return task;
72
+ }
73
+
74
+ // ../shared/src/lib/analyticsIngestError.ts
75
+ var import_zod = require("zod");
76
+ var IDENTIFY_INGEST_ERROR_SOURCE = "saasco-identify";
77
+ var ANALYTICS_INGEST_ERROR_SOURCE = "saasco-analytics";
78
+ var AnalyticsIngestErrorCodeSchema = import_zod.z.enum([
79
+ "CREDENTIAL_INVALID",
80
+ "RESOURCE_FORBIDDEN",
81
+ "RESOURCE_UNSPECIFIED",
82
+ "ENVIRONMENT_UNREACHABLE",
83
+ "INGEST_FAILED"
84
+ ]);
85
+ var AnalyticsIngestSurfaceSchema = import_zod.z.enum(["identify", "events"]);
86
+ var AnalyticsIngestErrorSchema = import_zod.z.object({
87
+ affected: import_zod.z.string(),
88
+ code: AnalyticsIngestErrorCodeSchema,
89
+ message: import_zod.z.string(),
90
+ projectId: import_zod.z.string().nullable(),
91
+ recovery: import_zod.z.string(),
92
+ resource: import_zod.z.string().nullable(),
93
+ surface: AnalyticsIngestSurfaceSchema
94
+ });
95
+ var IDENTIFY_AFFECTED = "Saasco identification only: CRM contact properties and identity stitching. Page views, charts, and the host application UI are not this failure.";
96
+ var ANALYTICS_EVENT_AFFECTED = "Saasco event tracking only. The host application UI is not this failure.";
97
+ function analyticsIngestError({
98
+ code,
99
+ message,
100
+ projectId,
101
+ resource,
102
+ surface
103
+ }) {
104
+ const copy = ingestCopy({ code, resource, surface });
105
+ return {
106
+ affected: surface === "identify" ? IDENTIFY_AFFECTED : ANALYTICS_EVENT_AFFECTED,
107
+ code,
108
+ message: message ?? copy.message,
109
+ projectId,
110
+ recovery: copy.recovery,
111
+ resource,
112
+ surface
113
+ };
114
+ }
115
+ function ingestCopy({
116
+ code,
117
+ resource,
118
+ surface
119
+ }) {
120
+ const named = resource ? `\`${resource}\`` : "the requested analytics resource";
121
+ const didNotComplete = surface === "identify" ? "Identification did not complete." : "Event tracking did not complete.";
122
+ switch (code) {
123
+ case "CREDENTIAL_INVALID": {
124
+ return {
125
+ message: "Saasco analytics credentials are invalid or missing. This is not an application login or permissions problem.",
126
+ recovery: "Check TINYBIRD_ANALYTICS_TOKEN on the Saasco ingest worker. Do not change host-app auth. Charts and UI styling are unrelated."
127
+ };
128
+ }
129
+ case "RESOURCE_FORBIDDEN": {
130
+ return {
131
+ message: `Saasco analytics is not allowed to use ${named}. ${didNotComplete}`,
132
+ 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).`
133
+ };
134
+ }
135
+ case "RESOURCE_UNSPECIFIED": {
136
+ return {
137
+ message: `Saasco analytics was denied access without a named pipe or datasource. ${didNotComplete}`,
138
+ 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."
139
+ };
140
+ }
141
+ case "ENVIRONMENT_UNREACHABLE": {
142
+ return {
143
+ 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.",
144
+ 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."
145
+ };
146
+ }
147
+ default: {
148
+ return {
149
+ message: surface === "identify" ? "Saasco identification could not be completed." : "Saasco event tracking could not be completed.",
150
+ recovery: "Retry. If it keeps failing, this is a Saasco analytics ingest issue, not host-app login or chart configuration."
151
+ };
152
+ }
153
+ }
154
+ }
155
+ function ingestErrorSource(surface) {
156
+ return surface === "identify" ? IDENTIFY_INGEST_ERROR_SOURCE : ANALYTICS_INGEST_ERROR_SOURCE;
157
+ }
158
+ function redactSecrets(text) {
159
+ return text.replaceAll(/Bearer\s+\S+/giu, "Bearer [redacted]").replaceAll(/\bp\.[A-Za-z0-9._-]+/gu, "[redacted]").replaceAll(/token=[^&\s]+/giu, "token=[redacted]");
160
+ }
161
+ function isUnsafeIngestMessage(text) {
162
+ 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(
163
+ text
164
+ );
165
+ }
166
+
167
+ // src/lib/formatAnalyticsRequestFailure.ts
168
+ function formatAnalyticsRequestFailure(input) {
169
+ const surface = input.path === "identify" ? "identify" : "events";
170
+ const projectId = input.projectId ?? null;
171
+ if (input.reason === "http") {
172
+ const parsed = AnalyticsIngestErrorSchema.safeParse(input.body);
173
+ if (parsed.success) {
174
+ return parsed.data;
175
+ }
176
+ }
177
+ if (input.reason === "unreachable") {
178
+ return analyticsIngestError({
179
+ code: "ENVIRONMENT_UNREACHABLE",
180
+ projectId,
181
+ resource: null,
182
+ surface
183
+ });
184
+ }
185
+ const legacy = input.body && typeof input.body === "object" && !Array.isArray(input.body) && "message" in input.body && typeof input.body.message === "string" ? input.body.message : "";
186
+ const safeMessage = redactSecrets(legacy).trim();
187
+ return analyticsIngestError({
188
+ code: "INGEST_FAILED",
189
+ message: safeMessage && !isUnsafeIngestMessage(safeMessage) ? safeMessage : void 0,
190
+ projectId,
191
+ resource: null,
192
+ surface
193
+ });
194
+ }
195
+ function reportIngestFailure({
196
+ failure,
197
+ logger,
198
+ path,
199
+ seen
200
+ }) {
201
+ const key = `${path}:${failure.code}:${failure.resource ?? ""}`;
202
+ if (!seen.has(key)) {
203
+ seen.add(key);
204
+ logger.warn(...analyticsFailureLogLines(failure, path));
205
+ }
206
+ return {
207
+ success: false,
208
+ message: failure.message
209
+ };
210
+ }
211
+ function analyticsFailureLogLines(failure, path) {
212
+ const lines = [
213
+ `${ingestErrorSource(failure.surface)} ${path} failed (${failure.code})`,
214
+ failure.message,
215
+ failure.resource ? `Resource: ${failure.resource}` : null,
216
+ failure.projectId ? `Project: ${failure.projectId}` : null,
217
+ `Affected: ${failure.affected}`,
218
+ `Recovery: ${failure.recovery}`
219
+ ];
220
+ return lines.filter((line) => line !== null);
221
+ }
42
222
 
43
223
  // src/lib/timezones.ts
44
224
  var timezones = {
@@ -518,12 +698,10 @@ var AnalyticsLogger = class {
518
698
  );
519
699
  }
520
700
  /**
521
- * Log warning information
701
+ * Warnings always print. Ingest failures use this so they stay visible when
702
+ * the default log level is ERROR, without `console.error` (Next.js overlays it).
522
703
  */
523
704
  warn(...args) {
524
- if (this.config.level < 1 /* WARN */) {
525
- return;
526
- }
527
705
  const icon = "\u25CD";
528
706
  const message = `${icon} ${this.config.label}`;
529
707
  console.warn(
@@ -669,6 +847,50 @@ function resolveAdParams(params) {
669
847
 
670
848
  // src/lib/integrations/facebook-pixel.ts
671
849
  var import_js_sha256 = require("js-sha256");
850
+
851
+ // src/lib/integrations/loadPixelScript.ts
852
+ var loads = /* @__PURE__ */ new Map();
853
+ function loadPixelScript(src) {
854
+ const existingLoad = loads.get(src);
855
+ if (existingLoad) {
856
+ return existingLoad;
857
+ }
858
+ const existingScript = Array.from(document.scripts).find(
859
+ (script2) => script2.src === src
860
+ );
861
+ const script = existingScript ?? document.createElement("script");
862
+ const task = new Promise((resolve, reject) => {
863
+ const cleanup = () => {
864
+ clearTimeout(timeout);
865
+ script.removeEventListener("load", loaded);
866
+ script.removeEventListener("error", failed);
867
+ };
868
+ const loaded = () => {
869
+ cleanup();
870
+ resolve();
871
+ };
872
+ const failed = () => {
873
+ cleanup();
874
+ script.remove();
875
+ reject(new Error(`Pixel script unavailable: ${src}`));
876
+ };
877
+ const timeout = setTimeout(failed, 1e4);
878
+ script.addEventListener("load", loaded);
879
+ script.addEventListener("error", failed);
880
+ if (!existingScript) {
881
+ script.async = true;
882
+ script.src = src;
883
+ document.head.append(script);
884
+ }
885
+ }).catch((error) => {
886
+ loads.delete(src);
887
+ throw error;
888
+ });
889
+ loads.set(src, task);
890
+ return task;
891
+ }
892
+
893
+ // src/lib/integrations/facebook-pixel.ts
672
894
  var isBrowser2 = typeof window !== "undefined";
673
895
  var isServer2 = !isBrowser2;
674
896
  var standardFacebookEvents = [
@@ -710,70 +932,32 @@ function createFacebookPixelIntegration(config, debug) {
710
932
  return;
711
933
  }
712
934
  logger.info("Initializing Facebook Pixel", { pixelId: config.pixelId });
713
- if (window.fbq || window._fbq) {
714
- logger.warn("Facebook Pixel is already initialized");
715
- isPixelReady = true;
716
- return;
717
- }
718
- return new Promise((resolve, reject) => {
719
- try {
720
- (function init(f, b, e, v, n, t, s) {
721
- if (f.fbq) {
722
- return;
723
- }
724
- n = f.fbq = function fbq2() {
725
- n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
726
- };
727
- if (!f._fbq) {
728
- f._fbq = n;
729
- }
730
- n.push = n;
731
- n.loaded = true;
732
- n.version = "2.0";
733
- n.queue = [];
734
- t = b.createElement(e);
735
- t.async = true;
736
- t.src = v;
737
- t.addEventListener("load", () => {
738
- isPixelReady = true;
739
- logger.info("Facebook Pixel script loaded successfully");
740
- resolve();
741
- });
742
- t.addEventListener("error", () => {
743
- logger.error("Failed to load Facebook Pixel script");
744
- reject(new Error("Failed to load Facebook Pixel script"));
745
- });
746
- s = b.getElementsByTagName(e)[0];
747
- s.parentNode.insertBefore(t, s);
748
- })(
749
- window,
750
- document,
751
- "script",
752
- "https://connect.facebook.net/en_US/fbevents.js"
753
- );
754
- const { fbq } = window;
755
- if (!fbq) {
756
- reject(new Error("Facebook Pixel failed to initialize"));
757
- return;
758
- }
759
- fbq.disablePushState = true;
760
- fbq.allowDuplicatePageViews = true;
761
- if (config.automaticConfiguration === false) {
762
- logger.debug("Disabling automatic configuration");
763
- fbq("set", "autoConfig", false, config.pixelId);
935
+ if (!window.fbq) {
936
+ const fbq2 = (...args) => {
937
+ if (fbq2.callMethod) {
938
+ fbq2.callMethod(...args);
939
+ } else {
940
+ fbq2.queue.push(args);
764
941
  }
765
- logger.debug("Initializing Facebook Pixel with ID", config.pixelId);
766
- fbq("init", config.pixelId);
767
- if (typeof fbq === "function") {
768
- isPixelReady = true;
769
- logger.info("Facebook Pixel initialized synchronously");
770
- resolve();
771
- }
772
- } catch (error) {
773
- logger.error("Error during Facebook Pixel initialization", error);
774
- reject(error);
775
- }
776
- });
942
+ };
943
+ fbq2.queue = [];
944
+ fbq2.push = fbq2;
945
+ fbq2.loaded = true;
946
+ fbq2.version = "2.0";
947
+ window.fbq = fbq2;
948
+ window._fbq = fbq2;
949
+ }
950
+ const { fbq } = window;
951
+ fbq.disablePushState = true;
952
+ fbq.allowDuplicatePageViews = true;
953
+ if (config.automaticConfiguration === false) {
954
+ fbq("set", "autoConfig", false, config.pixelId);
955
+ }
956
+ fbq("init", config.pixelId);
957
+ if (!fbq.callMethod) {
958
+ await loadPixelScript("https://connect.facebook.net/en_US/fbevents.js");
959
+ }
960
+ isPixelReady = true;
777
961
  },
778
962
  track: (eventName, properties, _context) => {
779
963
  const { fbq } = window;
@@ -781,9 +965,11 @@ function createFacebookPixelIntegration(config, debug) {
781
965
  logger.warn("Facebook Pixel not ready, skipping track event", {
782
966
  eventName
783
967
  });
784
- return;
968
+ throw new Error("Facebook Pixel not ready");
785
969
  }
786
970
  const fbEventName = getFacebookEventName(eventName, config.eventMapping);
971
+ const eventId = properties?.["event_id"];
972
+ const eventOptions = typeof eventId === "string" && eventId.trim().length > 0 ? { eventID: eventId } : void 0;
787
973
  const eventsWithParams = ["Purchase", "StartTrial", "Subscribe"];
788
974
  const isStandardEvent = standardFacebookEvents.includes(
789
975
  fbEventName
@@ -811,10 +997,18 @@ function createFacebookPixelIntegration(config, debug) {
811
997
  event: fbEventName,
812
998
  params: fbParams
813
999
  });
814
- fbq(trackType, fbEventName, fbParams);
1000
+ if (eventOptions) {
1001
+ fbq(trackType, fbEventName, fbParams, eventOptions);
1002
+ } else {
1003
+ fbq(trackType, fbEventName, fbParams);
1004
+ }
815
1005
  } else {
816
1006
  logger.info("Tracking Facebook Pixel event", { event: fbEventName });
817
- fbq(trackType, fbEventName);
1007
+ if (eventOptions) {
1008
+ fbq(trackType, fbEventName, {}, eventOptions);
1009
+ } else {
1010
+ fbq(trackType, fbEventName);
1011
+ }
818
1012
  }
819
1013
  },
820
1014
  identify: (userId, properties, _context) => {
@@ -1168,12 +1362,12 @@ var IntegrationManager = class {
1168
1362
  globalQueue = [];
1169
1363
  config;
1170
1364
  logger;
1171
- initTime;
1172
1365
  currentEnvironment;
1173
1366
  flushTimer;
1174
1367
  unloadHandler;
1175
1368
  constructor(config = {}) {
1176
1369
  this.config = {
1370
+ deduplicationNamespace: config.deduplicationNamespace ?? "default",
1177
1371
  flushInterval: config.flushInterval ?? 5e3,
1178
1372
  loggerLevel: config.loggerLevel ?? 0 /* ERROR */,
1179
1373
  maxIntegrationWaitTime: config.maxIntegrationWaitTime ?? 1e4,
@@ -1183,7 +1377,6 @@ var IntegrationManager = class {
1183
1377
  label: "Saasco Integrations Debug",
1184
1378
  level: this.config.loggerLevel
1185
1379
  });
1186
- this.initTime = Date.now();
1187
1380
  this.currentEnvironment = typeof window === "undefined" ? "server" : "client";
1188
1381
  this.setupPeriodicFlushing();
1189
1382
  this.setupUnloadHandler();
@@ -1260,6 +1453,8 @@ var IntegrationManager = class {
1260
1453
  */
1261
1454
  send(partial) {
1262
1455
  const envelope = {
1456
+ delivered: /* @__PURE__ */ new Set(),
1457
+ pending: /* @__PURE__ */ new Set(),
1263
1458
  context: { ...this.context },
1264
1459
  id: uuid(),
1265
1460
  name: partial.name,
@@ -1267,65 +1462,55 @@ var IntegrationManager = class {
1267
1462
  timestamp: Date.now(),
1268
1463
  type: partial.type
1269
1464
  };
1270
- if (!this.isReady()) {
1271
- if (this.globalQueue.length >= this.config.maxQueueSize) {
1272
- this.globalQueue.shift();
1273
- this.logger.warn("Queue full \u2192 dropped oldest");
1274
- }
1275
- this.globalQueue.push(envelope);
1276
- this.logger.debug(`Queued (${this.globalQueue.length})`, envelope);
1277
- return;
1465
+ if (this.globalQueue.length >= this.config.maxQueueSize) {
1466
+ this.globalQueue.shift();
1467
+ this.logger.warn("Queue full \u2192 dropped oldest");
1278
1468
  }
1279
- this.deliver(envelope);
1469
+ this.globalQueue.push(envelope);
1470
+ this.flush();
1280
1471
  }
1281
- async deliver(evt) {
1282
- for (const { integration, status } of this.integrations.values()) {
1283
- if (status !== "ready") {
1284
- continue;
1285
- }
1286
- try {
1287
- if (evt.type === "track" && integration.track && evt.name) {
1288
- this.logger.debug(`Track (${integration.name}) ${evt.name}`, {
1289
- context: evt.context,
1290
- properties: evt.properties
1291
- });
1292
- await integration.track(evt.name, evt.properties, evt.context);
1293
- } else if (evt.type === "identify" && integration.identify) {
1294
- this.logger.debug(`Identify (${integration.name})`, {
1295
- context: evt.context,
1296
- properties: evt.properties,
1297
- userId: evt.context.distinctId
1298
- });
1299
- await integration.identify(
1300
- evt.context.distinctId,
1301
- evt.properties,
1302
- evt.context
1303
- );
1304
- }
1305
- } catch (error) {
1306
- this.logger.error(
1307
- `deliver error in ${integration.name} (${evt.type})`,
1308
- error
1472
+ async deliver(evt, state) {
1473
+ const { integration } = state;
1474
+ evt.pending.add(integration.name);
1475
+ try {
1476
+ if (evt.type === "track" && integration.track && evt.name) {
1477
+ const eventId = evt.properties?.["event_id"];
1478
+ const key = typeof eventId === "string" && eventId.trim() ? `saasco:${this.config.deduplicationNamespace}:${integration.name}:${evt.name}:${eventId}` : void 0;
1479
+ const { track } = integration;
1480
+ const { name } = evt;
1481
+ await deliverOnce(
1482
+ key,
1483
+ () => track.call(integration, name, evt.properties, evt.context)
1484
+ );
1485
+ } else if (evt.type === "identify" && integration.identify) {
1486
+ await integration.identify(
1487
+ evt.context.distinctId,
1488
+ evt.properties,
1489
+ evt.context
1309
1490
  );
1310
1491
  }
1492
+ evt.delivered.add(integration.name);
1493
+ } catch (error) {
1494
+ this.logger.error(
1495
+ `deliver error in ${integration.name} (${evt.type})`,
1496
+ error
1497
+ );
1498
+ } finally {
1499
+ evt.pending.delete(integration.name);
1311
1500
  }
1312
1501
  }
1313
- /**
1314
- * Flush queued events FIFO once at least one integration is ready.
1315
- */
1502
+ /** Each ready destination drains its own copy; a slow/blocked pixel cannot
1503
+ * discard another pixel's queued events or hold up the ready destinations. */
1316
1504
  flush() {
1317
- if (this.globalQueue.length === 0) {
1318
- return;
1319
- }
1320
- if (!this.isReady()) {
1321
- return;
1322
- }
1323
- this.logger.debug(`flushing ${this.globalQueue.length} queued events`);
1324
- const toSend = this.globalQueue;
1325
- this.globalQueue = [];
1326
- for (const evt of toSend) {
1327
- this.deliver(evt);
1328
- }
1505
+ this.globalQueue = this.globalQueue.filter((evt) => {
1506
+ for (const state of this.integrations.values()) {
1507
+ const { name } = state.integration;
1508
+ if (state.status === "ready" && !evt.delivered.has(name) && !evt.pending.has(name)) {
1509
+ void this.deliver(evt, state);
1510
+ }
1511
+ }
1512
+ return evt.delivered.size < this.integrations.size && Date.now() - evt.timestamp < this.config.maxIntegrationWaitTime;
1513
+ });
1329
1514
  }
1330
1515
  readyCount() {
1331
1516
  let n = 0;
@@ -1336,18 +1521,6 @@ var IntegrationManager = class {
1336
1521
  }
1337
1522
  return n;
1338
1523
  }
1339
- isReady() {
1340
- const timeElapsed = Date.now() - this.initTime;
1341
- if (this.readyCount() < this.integrations.size && timeElapsed < this.config.maxIntegrationWaitTime) {
1342
- return false;
1343
- }
1344
- if (this.readyCount() < this.integrations.size) {
1345
- this.logger.info(
1346
- `proceeding with ${this.readyCount()}/${this.integrations.size} integrations ready after ${timeElapsed}ms wait`
1347
- );
1348
- }
1349
- return true;
1350
- }
1351
1524
  /**
1352
1525
  * Setup periodic flushing if enabled
1353
1526
  */
@@ -1431,50 +1604,20 @@ function createPinterestTagIntegration(config, debug) {
1431
1604
  return;
1432
1605
  }
1433
1606
  logger.info("Initializing Pinterest Tag", { tagId: config.tagId });
1434
- if (window.pintrk || window._pintrk) {
1435
- logger.warn("Pinterest Tag is already initialized");
1436
- isTagReady = true;
1437
- return;
1607
+ if (!window.pintrk) {
1608
+ const pintrk = (...args) => pintrk.queue.push(args);
1609
+ pintrk.queue = [];
1610
+ pintrk.version = "3.0";
1611
+ window.pintrk = pintrk;
1438
1612
  }
1439
- return new Promise((resolve, reject) => {
1440
- try {
1441
- (function init(e) {
1442
- if (!window.pintrk) {
1443
- window.pintrk = function pintrk(...args) {
1444
- window.pintrk.queue.push(args);
1445
- };
1446
- const n = window.pintrk;
1447
- n.queue = [];
1448
- n.version = "3.0";
1449
- const t = document.createElement("script");
1450
- t.async = true;
1451
- t.src = e;
1452
- t.addEventListener("load", () => {
1453
- window.pintrk("load", config.tagId);
1454
- window.pintrk("page");
1455
- setTimeout(() => {
1456
- logger.debug("Pinterest Tag initialized");
1457
- isTagReady = true;
1458
- resolve();
1459
- }, 500);
1460
- });
1461
- t.addEventListener("error", () => {
1462
- logger.error("Failed to load Pinterest Tag script");
1463
- reject(new Error("Failed to load Pinterest Tag script"));
1464
- });
1465
- const r = document.getElementsByTagName("script")[0];
1466
- r.parentNode.insertBefore(t, r);
1467
- }
1468
- })("https://s.pinimg.com/ct/core.js");
1469
- } catch (error) {
1470
- logger.error("Failed to initialize Pinterest Tag", error);
1471
- reject(error);
1472
- }
1473
- });
1613
+ await loadPixelScript("https://s.pinimg.com/ct/core.js");
1614
+ window.pintrk("load", config.tagId);
1615
+ window.pintrk("page");
1616
+ isTagReady = true;
1474
1617
  },
1475
1618
  track: (eventName, properties) => {
1476
1619
  if (!isTagReady || !window.pintrk) {
1477
- return;
1620
+ throw new Error("Pinterest Tag not ready");
1478
1621
  }
1479
1622
  const pinterestEventName = getPinterestEventName(
1480
1623
  eventName,
@@ -1603,8 +1746,8 @@ function createTikTokPixelIntegration(config, debug) {
1603
1746
  return;
1604
1747
  }
1605
1748
  logger.info("Initializing TikTok Pixel", { pixelId: config.pixelId });
1606
- if (window.ttq) {
1607
- logger.warn("TikTok Pixel is already initialized");
1749
+ if (window.ttq?.ready) {
1750
+ await new Promise((resolve) => window.ttq.ready(resolve));
1608
1751
  isPixelReady = true;
1609
1752
  return;
1610
1753
  }
@@ -1656,28 +1799,15 @@ function createTikTokPixelIntegration(config, debug) {
1656
1799
  ttq._t[e] = Date.now();
1657
1800
  ttq._o ||= {};
1658
1801
  ttq._o[e] = n || {};
1659
- const script = document.createElement("script");
1660
- script.type = "text/javascript";
1661
- script.async = true;
1662
- script.src = `${r}?sdkid=${e}&lib=${t}`;
1663
- script.addEventListener("load", () => {
1802
+ loadPixelScript(`${r}?sdkid=${e}&lib=${t}`).then(() => {
1664
1803
  isPixelReady = true;
1665
1804
  resolve();
1666
- });
1667
- script.addEventListener("error", () => {
1668
- reject(new Error("Failed to load TikTok Pixel script"));
1669
- });
1670
- const firstScript = document.getElementsByTagName("script")[0];
1671
- firstScript.parentNode.insertBefore(script, firstScript);
1805
+ }).catch(reject);
1672
1806
  };
1673
1807
  ttq.load(
1674
1808
  config.pixelId,
1675
1809
  config.testMode ? { test_mode: true } : void 0
1676
1810
  );
1677
- if (window.ttq && typeof window.ttq === "function") {
1678
- isPixelReady = true;
1679
- resolve();
1680
- }
1681
1811
  })(window, document, "ttq");
1682
1812
  } catch (error) {
1683
1813
  reject(error);
@@ -1686,8 +1816,8 @@ function createTikTokPixelIntegration(config, debug) {
1686
1816
  },
1687
1817
  track: (eventName, properties, _context) => {
1688
1818
  const { ttq } = window;
1689
- if (!isPixelReady || !ttq) {
1690
- return;
1819
+ if (!isPixelReady || !ttq?.track) {
1820
+ throw new Error("TikTok Pixel not ready");
1691
1821
  }
1692
1822
  const ttEventName = getTikTokEventName(eventName, config.eventMapping);
1693
1823
  if (ttEventName === "PageView") {
@@ -1695,7 +1825,10 @@ function createTikTokPixelIntegration(config, debug) {
1695
1825
  return;
1696
1826
  }
1697
1827
  const ttParams = mapTikTokEventParameters(ttEventName, properties);
1698
- if (ttParams && Object.keys(ttParams).length > 0) {
1828
+ const eventId = properties?.["event_id"];
1829
+ if (typeof eventId === "string" && eventId.trim().length > 0) {
1830
+ ttq.track?.(ttEventName, ttParams ?? {}, { event_id: eventId });
1831
+ } else if (ttParams && Object.keys(ttParams).length > 0) {
1699
1832
  ttq.track?.(ttEventName, ttParams);
1700
1833
  } else {
1701
1834
  ttq.track?.(ttEventName);
@@ -2215,6 +2348,15 @@ function migrateFromLocalStorage() {
2215
2348
  if (isServer5) {
2216
2349
  return;
2217
2350
  }
2351
+ let storage;
2352
+ try {
2353
+ storage = globalThis.localStorage ?? null;
2354
+ } catch {
2355
+ return;
2356
+ }
2357
+ if (!storage) {
2358
+ return;
2359
+ }
2218
2360
  const keysToMigrate = [
2219
2361
  `${PREF}-session-id`,
2220
2362
  `${PREF}-anonymous-id`,
@@ -2224,20 +2366,23 @@ function migrateFromLocalStorage() {
2224
2366
  ];
2225
2367
  for (const key of keysToMigrate) {
2226
2368
  try {
2227
- const value = localStorage.getItem(key);
2369
+ const value = storage.getItem(key);
2228
2370
  if (value) {
2229
2371
  const item = JSON.parse(value);
2230
2372
  const now = Date.now();
2231
2373
  if (item.expiry > now) {
2232
2374
  const remainingTtl = item.expiry - now;
2233
2375
  setCookie(key, JSON.stringify(item.value), remainingTtl);
2234
- localStorage.removeItem(key);
2376
+ storage.removeItem(key);
2235
2377
  } else {
2236
- localStorage.removeItem(key);
2378
+ storage.removeItem(key);
2237
2379
  }
2238
2380
  }
2239
2381
  } catch {
2240
- localStorage.removeItem(key);
2382
+ try {
2383
+ storage.removeItem(key);
2384
+ } catch {
2385
+ }
2241
2386
  }
2242
2387
  }
2243
2388
  }
@@ -2468,7 +2613,8 @@ var Saasco = class {
2468
2613
  };
2469
2614
  this.integrationManager = new IntegrationManager({
2470
2615
  loggerLevel,
2471
- maxQueueSize: 1e3
2616
+ maxQueueSize: 1e3,
2617
+ deduplicationNamespace: this.config.projectId
2472
2618
  });
2473
2619
  this.integrationManager.setContext({});
2474
2620
  void (async () => {
@@ -2491,6 +2637,7 @@ var Saasco = class {
2491
2637
  // here so an `identify()` that fires before the widget bundle finishes
2492
2638
  // loading is replayed once it does.
2493
2639
  lastSupportIdentity = null;
2640
+ ingestFailureKeys = /* @__PURE__ */ new Set();
2494
2641
  init() {
2495
2642
  if (this.isInitialized) {
2496
2643
  this.logger.info(
@@ -2542,48 +2689,52 @@ var Saasco = class {
2542
2689
  );
2543
2690
  return;
2544
2691
  }
2545
- for (const integrationConfig of this.config.integrations) {
2546
- try {
2547
- if (integrationConfig.type === "facebook-pixel") {
2548
- this.logger.info(
2549
- "Registering Facebook Pixel integration with ID:",
2550
- integrationConfig.config.pixelId
2551
- );
2552
- const fbIntegration = createFacebookPixelIntegration(
2553
- integrationConfig.config,
2554
- integrationConfig.debug
2555
- );
2556
- await this.integrationManager.registerIntegration(fbIntegration);
2557
- } else if (integrationConfig.type === "pinterest-tag") {
2558
- this.logger.info(
2559
- "Registering Pinterest Tag integration with ID:",
2560
- integrationConfig.config.tagId
2561
- );
2562
- const pinterestIntegration = createPinterestTagIntegration(
2563
- integrationConfig.config,
2564
- integrationConfig.debug
2565
- );
2566
- await this.integrationManager.registerIntegration(
2567
- pinterestIntegration
2568
- );
2569
- } else if (integrationConfig.type === "tiktok-pixel") {
2692
+ await Promise.all(
2693
+ this.config.integrations.map(async (integrationConfig) => {
2694
+ try {
2695
+ if (integrationConfig.type === "facebook-pixel") {
2696
+ this.logger.info(
2697
+ "Registering Facebook Pixel integration with ID:",
2698
+ integrationConfig.config.pixelId
2699
+ );
2700
+ const fbIntegration = createFacebookPixelIntegration(
2701
+ integrationConfig.config,
2702
+ integrationConfig.debug
2703
+ );
2704
+ await this.integrationManager.registerIntegration(fbIntegration);
2705
+ } else if (integrationConfig.type === "pinterest-tag") {
2706
+ this.logger.info(
2707
+ "Registering Pinterest Tag integration with ID:",
2708
+ integrationConfig.config.tagId
2709
+ );
2710
+ const pinterestIntegration = createPinterestTagIntegration(
2711
+ integrationConfig.config,
2712
+ integrationConfig.debug
2713
+ );
2714
+ await this.integrationManager.registerIntegration(
2715
+ pinterestIntegration
2716
+ );
2717
+ } else if (integrationConfig.type === "tiktok-pixel") {
2718
+ this.logger.info(
2719
+ "Registering TikTok Pixel integration with ID:",
2720
+ integrationConfig.config.pixelId
2721
+ );
2722
+ const tiktokIntegration = createTikTokPixelIntegration(
2723
+ integrationConfig.config,
2724
+ integrationConfig.debug
2725
+ );
2726
+ await this.integrationManager.registerIntegration(
2727
+ tiktokIntegration
2728
+ );
2729
+ }
2730
+ } catch (error) {
2570
2731
  this.logger.info(
2571
- "Registering TikTok Pixel integration with ID:",
2572
- integrationConfig.config.pixelId
2573
- );
2574
- const tiktokIntegration = createTikTokPixelIntegration(
2575
- integrationConfig.config,
2576
- integrationConfig.debug
2732
+ `Failed to register ${integrationConfig.type} integration:`,
2733
+ error
2577
2734
  );
2578
- await this.integrationManager.registerIntegration(tiktokIntegration);
2579
2735
  }
2580
- } catch (error) {
2581
- this.logger.info(
2582
- `Failed to register ${integrationConfig.type} integration:`,
2583
- error
2584
- );
2585
- }
2586
- }
2736
+ })
2737
+ );
2587
2738
  }
2588
2739
  track(actionOrPayload, propertiesOrNothing, contextOrNothing) {
2589
2740
  if (!this.config.projectId) {
@@ -2640,7 +2791,19 @@ var Saasco = class {
2640
2791
  });
2641
2792
  }
2642
2793
  this.integrationManager.track(action, properties);
2643
- return this.doRequest("events", requestData);
2794
+ const eventId = properties?.["event_id"];
2795
+ if (!isBrowser5 || typeof eventId !== "string" || !eventId.trim()) {
2796
+ return this.doRequest("events", requestData);
2797
+ }
2798
+ return deliverOnce(
2799
+ `saasco:${this.config.projectId}:ingest:${action}:${eventId}`,
2800
+ async () => {
2801
+ const result = await this.doRequest("events", requestData);
2802
+ if (!result.success) {
2803
+ throw new Error(result.message);
2804
+ }
2805
+ }
2806
+ ).then(() => ({ success: true, message: "Event dispatched" })).catch((error) => ({ success: false, message: String(error) }));
2644
2807
  }
2645
2808
  /**
2646
2809
  * The page method lets you record page views on your website
@@ -2740,7 +2903,10 @@ var Saasco = class {
2740
2903
  },
2741
2904
  context: context || {}
2742
2905
  };
2743
- this.logger.info("identify", requestData);
2906
+ this.logger.info("identify", {
2907
+ distinctId,
2908
+ projectId: this.config.projectId
2909
+ });
2744
2910
  if (!this.config.enabled) {
2745
2911
  return Promise.resolve({
2746
2912
  success: true,
@@ -2776,6 +2942,13 @@ var Saasco = class {
2776
2942
  message: "Analytics is disabled"
2777
2943
  };
2778
2944
  }
2945
+ const projectId = requestData.projectId ?? null;
2946
+ const fail = (input) => reportIngestFailure({
2947
+ failure: formatAnalyticsRequestFailure({ path, projectId, ...input }),
2948
+ logger: this.logger,
2949
+ path,
2950
+ seen: this.ingestFailureKeys
2951
+ });
2779
2952
  let response;
2780
2953
  try {
2781
2954
  response = await fetch(url, {
@@ -2785,46 +2958,22 @@ var Saasco = class {
2785
2958
  },
2786
2959
  body: JSON.stringify(requestData)
2787
2960
  });
2788
- } catch (error) {
2789
- const message = error instanceof Error ? error.message : String(error);
2790
- this.logger.warn(
2791
- `Network request failed`,
2792
- `
2793
- Error Message: "${message}"`,
2794
- `
2795
- Url: "${url}"`
2796
- );
2797
- return {
2798
- success: false,
2799
- message
2800
- };
2961
+ } catch {
2962
+ return fail({ reason: "unreachable" });
2801
2963
  }
2964
+ let responseBody;
2802
2965
  try {
2803
- const responseBody = await response.json();
2804
- if (!response.ok) {
2805
- throw new Error(
2806
- `${responseBody.message}: ${responseBody.errors ? `[${responseBody.errors.join(", ")}]` : ""}` || JSON.stringify(responseBody)
2807
- );
2808
- }
2809
- return {
2810
- success: true,
2811
- message: responseBody.message ?? ""
2812
- };
2813
- } catch (error) {
2814
- const message = error instanceof Error ? error.message : String(error);
2815
- this.logger.error(
2816
- `
2817
- Error Message: "${message}"`,
2818
- `
2819
- Path: "/${path}"`,
2820
- `
2821
- Request Data: ${JSON.stringify(requestData, null, 2)}`
2822
- );
2823
- return {
2824
- success: false,
2825
- message
2826
- };
2966
+ responseBody = await response.json();
2967
+ } catch {
2968
+ return fail({ reason: "http" });
2969
+ }
2970
+ if (!response.ok) {
2971
+ return fail({ reason: "http", body: responseBody });
2827
2972
  }
2973
+ return {
2974
+ success: true,
2975
+ message: responseBody && typeof responseBody === "object" && "message" in responseBody && typeof responseBody.message === "string" ? responseBody.message : ""
2976
+ };
2828
2977
  }
2829
2978
  /**
2830
2979
  * If autoPageTracking is enabled, this will automatically track page views
@@ -3052,31 +3201,31 @@ function getLoggerLevel(config) {
3052
3201
  }
3053
3202
 
3054
3203
  // ../shared/src/lib/types/browserContext.ts
3055
- var import_zod = require("zod");
3056
- var BrowserContextSchema = import_zod.z.object({
3057
- $href: import_zod.z.string(),
3058
- $locale: import_zod.z.string(),
3059
- $location: import_zod.z.string(),
3060
- $pathname: import_zod.z.string(),
3061
- $referrer: import_zod.z.string(),
3062
- $referringDomain: import_zod.z.string(),
3063
- $screenDPI: import_zod.z.number(),
3064
- $screenHeight: import_zod.z.number(),
3065
- $screenWidth: import_zod.z.number(),
3066
- $title: import_zod.z.string(),
3067
- $userAgent: import_zod.z.string(),
3068
- $utmAdId: import_zod.z.string().nullable(),
3069
- $utmAdSource: import_zod.z.string().nullable(),
3070
- $utmCampaign: import_zod.z.string().nullable(),
3071
- $utmCampaignId: import_zod.z.string().nullable(),
3072
- $utmContent: import_zod.z.string().nullable(),
3073
- $utmCreativeFormat: import_zod.z.string().nullable(),
3074
- $utmId: import_zod.z.string().nullable(),
3075
- $utmMarketingTactic: import_zod.z.string().nullable(),
3076
- $utmMedium: import_zod.z.string().nullable(),
3077
- $utmSource: import_zod.z.string().nullable(),
3078
- $utmSourcePlatform: import_zod.z.string().nullable(),
3079
- $utmTerm: import_zod.z.string().nullable()
3204
+ var import_zod2 = require("zod");
3205
+ var BrowserContextSchema = import_zod2.z.object({
3206
+ $href: import_zod2.z.string(),
3207
+ $locale: import_zod2.z.string(),
3208
+ $location: import_zod2.z.string(),
3209
+ $pathname: import_zod2.z.string(),
3210
+ $referrer: import_zod2.z.string(),
3211
+ $referringDomain: import_zod2.z.string(),
3212
+ $screenDPI: import_zod2.z.number(),
3213
+ $screenHeight: import_zod2.z.number(),
3214
+ $screenWidth: import_zod2.z.number(),
3215
+ $title: import_zod2.z.string(),
3216
+ $userAgent: import_zod2.z.string(),
3217
+ $utmAdId: import_zod2.z.string().nullable(),
3218
+ $utmAdSource: import_zod2.z.string().nullable(),
3219
+ $utmCampaign: import_zod2.z.string().nullable(),
3220
+ $utmCampaignId: import_zod2.z.string().nullable(),
3221
+ $utmContent: import_zod2.z.string().nullable(),
3222
+ $utmCreativeFormat: import_zod2.z.string().nullable(),
3223
+ $utmId: import_zod2.z.string().nullable(),
3224
+ $utmMarketingTactic: import_zod2.z.string().nullable(),
3225
+ $utmMedium: import_zod2.z.string().nullable(),
3226
+ $utmSource: import_zod2.z.string().nullable(),
3227
+ $utmSourcePlatform: import_zod2.z.string().nullable(),
3228
+ $utmTerm: import_zod2.z.string().nullable()
3080
3229
  });
3081
3230
  // Annotate the CommonJS export names for ESM import in node:
3082
3231
  0 && (module.exports = {