thumbgate 1.23.1 → 1.23.2

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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +5 -5
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.well-known/llms.txt +26 -11
  4. package/.well-known/mcp/server-card.json +8 -8
  5. package/README.md +69 -34
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/mcp/server-stdio.js +1 -1
  8. package/adapters/opencode/opencode.json +1 -1
  9. package/bin/cli.js +39 -16
  10. package/bin/postinstall.js +11 -22
  11. package/config/gate-templates.json +72 -0
  12. package/config/github-about.json +1 -1
  13. package/config/post-deploy-marketing-pages.json +6 -1
  14. package/package.json +5 -5
  15. package/public/agent-manager.html +3 -3
  16. package/public/agents-cost-savings.html +3 -3
  17. package/public/ai-malpractice-prevention.html +278 -7
  18. package/public/blog.html +3 -3
  19. package/public/codex-enterprise.html +3 -3
  20. package/public/codex-plugin.html +4 -4
  21. package/public/compare.html +6 -6
  22. package/public/dashboard.html +211 -126
  23. package/public/guide.html +5 -5
  24. package/public/index.html +156 -47
  25. package/public/learn.html +24 -10
  26. package/public/lessons.html +2 -2
  27. package/public/numbers.html +6 -6
  28. package/public/pricing.html +6 -5
  29. package/public/pro.html +1 -0
  30. package/scripts/billing.js +17 -0
  31. package/scripts/commercial-offer.js +4 -1
  32. package/scripts/dashboard.js +53 -1
  33. package/scripts/gates-engine.js +3 -3
  34. package/scripts/plausible-server-events.js +2 -1
  35. package/scripts/rate-limiter.js +16 -12
  36. package/scripts/seo-gsd.js +167 -1
  37. package/scripts/telemetry-analytics.js +310 -0
  38. package/scripts/visitor-journey.js +172 -0
  39. package/src/api/server.js +65 -29
  40. package/adapters/chatgpt/openapi.yaml +0 -1705
@@ -255,6 +255,149 @@ function inferTrafficChannel(raw = {}, referrerHost = null) {
255
255
  return 'referral';
256
256
  }
257
257
 
258
+ function normalizeBoolean(value) {
259
+ if (value === true || value === false) return value;
260
+ const text = normalizeText(value, 32);
261
+ if (!text) return false;
262
+ return ['1', 'true', 'yes', 'y', 'bot'].includes(text.toLowerCase());
263
+ }
264
+
265
+ function includesAnyToken(values, tokens) {
266
+ const haystack = values
267
+ .map((value) => normalizeText(value, 512))
268
+ .filter(Boolean)
269
+ .join(' ')
270
+ .toLowerCase();
271
+ if (!haystack) return false;
272
+ return tokens.some((token) => haystack.includes(token));
273
+ }
274
+
275
+ function addAudienceReason(reasons, code) {
276
+ if (!reasons.includes(code)) reasons.push(code);
277
+ }
278
+
279
+ function classifyTelemetryAudience(entry = {}, raw = {}) {
280
+ const reasons = [];
281
+ const identityValues = [
282
+ raw.email,
283
+ raw.customerEmail,
284
+ raw.buyerEmail,
285
+ raw.userEmail,
286
+ entry.visitorId,
287
+ entry.sessionId,
288
+ entry.traceId,
289
+ entry.acquisitionId,
290
+ entry.installId,
291
+ ];
292
+ const attributionValues = [
293
+ entry.source,
294
+ entry.utmSource,
295
+ entry.utmMedium,
296
+ entry.utmCampaign,
297
+ entry.utmContent,
298
+ entry.creator,
299
+ entry.offerCode,
300
+ entry.campaignVariant,
301
+ entry.referrerHost,
302
+ ];
303
+ const eventValues = [
304
+ entry.eventType,
305
+ entry.event,
306
+ entry.page,
307
+ entry.landingPath,
308
+ entry.ctaId,
309
+ entry.reasonCode,
310
+ entry.reasonDetail,
311
+ ];
312
+ const userAgent = normalizeText(entry.userAgent || raw.userAgent, 512) || '';
313
+ const hostValues = [raw.host, raw.hostname, raw.origin, raw.url, raw.pageUrl, entry.referrerHost];
314
+
315
+ if (normalizeBoolean(entry.isBot || raw.isBot) || includesAnyToken([userAgent], [
316
+ 'bot',
317
+ 'crawler',
318
+ 'spider',
319
+ 'headless',
320
+ 'playwright',
321
+ 'puppeteer',
322
+ 'curl/',
323
+ 'wget/',
324
+ 'lighthouse',
325
+ ])) {
326
+ addAudienceReason(reasons, 'bot_or_automation_user_agent');
327
+ }
328
+
329
+ if (includesAnyToken(identityValues, [
330
+ '@example.com',
331
+ 'buyer@example.com',
332
+ 'test@example.com',
333
+ 'codex-verification',
334
+ 'audit@thumbgate.ai',
335
+ ])) {
336
+ addAudienceReason(reasons, 'test_identity');
337
+ }
338
+
339
+ if (includesAnyToken([...attributionValues, ...eventValues], [
340
+ 'codex',
341
+ 'audit',
342
+ 'verification',
343
+ 'synthetic',
344
+ 'smoke',
345
+ 'probe',
346
+ 'test',
347
+ ])) {
348
+ addAudienceReason(reasons, 'test_or_audit_attribution');
349
+ }
350
+
351
+ if (includesAnyToken(hostValues, [
352
+ 'localhost',
353
+ '127.0.0.1',
354
+ '::1',
355
+ 'thumbgate-production.up.railway.app',
356
+ ])) {
357
+ addAudienceReason(reasons, 'internal_host');
358
+ }
359
+
360
+ if (includesAnyToken([userAgent, ...identityValues], [
361
+ 'codex',
362
+ 'github-actions',
363
+ 'node-fetch',
364
+ 'thumbgate-analytics',
365
+ 'thumbgate-verification',
366
+ ])) {
367
+ addAudienceReason(reasons, 'internal_operator_or_ci');
368
+ }
369
+
370
+ if (
371
+ (entry.clientType || entry.client) === 'unknown' &&
372
+ !pickFirstText(entry.visitorId, entry.sessionId, entry.acquisitionId, entry.installId, entry.traceId) &&
373
+ !userAgent &&
374
+ includesAnyToken([entry.source, entry.utmSource], ['direct', 'website'])
375
+ ) {
376
+ addAudienceReason(reasons, 'unknown_direct_no_identity');
377
+ }
378
+
379
+ const audience = reasons.includes('test_identity') || reasons.includes('test_or_audit_attribution')
380
+ ? 'test'
381
+ : (reasons.includes('bot_or_automation_user_agent')
382
+ ? 'bot'
383
+ : (
384
+ reasons.includes('internal_host') ||
385
+ reasons.includes('internal_operator_or_ci') ||
386
+ reasons.includes('unknown_direct_no_identity')
387
+ ? 'internal'
388
+ : 'external'
389
+ ));
390
+
391
+ return {
392
+ audience,
393
+ reasons,
394
+ isExternal: audience === 'external',
395
+ isInternal: audience === 'internal',
396
+ isTest: audience === 'test',
397
+ isBot: audience === 'bot',
398
+ };
399
+ }
400
+
258
401
  function sanitizeTelemetryPayload(payload = {}, headers = {}) {
259
402
  const raw = payload && typeof payload === 'object' ? payload : {};
260
403
  const clientType = inferClientType(raw);
@@ -342,6 +485,15 @@ function sanitizeTelemetryPayload(payload = {}, headers = {}) {
342
485
  ),
343
486
  };
344
487
 
488
+ const audience = classifyTelemetryAudience(entry, raw);
489
+ entry.audience = audience.audience;
490
+ entry.trafficAudience = audience.audience;
491
+ entry.audienceReasons = audience.reasons;
492
+ entry.isExternal = audience.isExternal;
493
+ entry.isInternal = audience.isInternal;
494
+ entry.isTest = audience.isTest;
495
+ entry.isBotTraffic = audience.isBot;
496
+
345
497
  return entry;
346
498
  }
347
499
 
@@ -432,9 +584,162 @@ function summarizeRecentEvents(events) {
432
584
  community: entry.community || null,
433
585
  offerCode: entry.offerCode || null,
434
586
  campaignVariant: entry.campaignVariant || null,
587
+ audience: entry.audience || 'external',
588
+ audienceReasons: entry.audienceReasons || [],
435
589
  }));
436
590
  }
437
591
 
592
+ function summarizeExternalTrafficQuality(events) {
593
+ const byAudience = {};
594
+ const byExclusionReason = {};
595
+ const externalVisitors = new Set();
596
+ const externalSessions = new Set();
597
+ const externalCheckoutStarters = new Set();
598
+ const pageViewsBySource = {};
599
+ const pageViewsByPath = {};
600
+ const pageViewsByTrafficChannel = {};
601
+ const checkoutStartsBySource = {};
602
+ const checkoutStartsByTrafficChannel = {};
603
+ const buyerLossReasons = {};
604
+ const visitorPaths = new Map();
605
+ let externalEvents = 0;
606
+ let excludedEvents = 0;
607
+ let externalWebEvents = 0;
608
+ let externalPageViews = 0;
609
+ let externalCtaClicks = 0;
610
+ let externalCheckoutStarts = 0;
611
+ let externalBuyerLossSignals = 0;
612
+
613
+ for (const entry of events) {
614
+ const audience = entry.audience || 'external';
615
+ incrementCounter(byAudience, audience);
616
+ if (audience !== 'external') {
617
+ excludedEvents += 1;
618
+ const reasons = Array.isArray(entry.audienceReasons) && entry.audienceReasons.length > 0
619
+ ? entry.audienceReasons
620
+ : [`${audience}_traffic`];
621
+ for (const reason of reasons) incrementCounter(byExclusionReason, reason);
622
+ continue;
623
+ }
624
+
625
+ externalEvents += 1;
626
+ if ((entry.clientType || entry.client) !== 'web') continue;
627
+
628
+ externalWebEvents += 1;
629
+ const visitorKey = pickFirstText(entry.visitorId, entry.installId, entry.sessionId);
630
+ if (visitorKey) externalVisitors.add(visitorKey);
631
+ if (entry.sessionId) externalSessions.add(entry.sessionId);
632
+
633
+ const eventType = entry.eventType || entry.event;
634
+ if (visitorKey) {
635
+ const pathRow = visitorPaths.get(visitorKey) || {
636
+ visitorKey,
637
+ firstSeenAt: entry.receivedAt || null,
638
+ lastSeenAt: entry.receivedAt || null,
639
+ source: entry.source || null,
640
+ trafficChannel: entry.trafficChannel || null,
641
+ events: [],
642
+ pages: [],
643
+ checkoutStarts: 0,
644
+ };
645
+ if (!pathRow.firstSeenAt || String(entry.receivedAt || '') < pathRow.firstSeenAt) {
646
+ pathRow.firstSeenAt = entry.receivedAt || null;
647
+ }
648
+ if (!pathRow.lastSeenAt || String(entry.receivedAt || '') > pathRow.lastSeenAt) {
649
+ pathRow.lastSeenAt = entry.receivedAt || null;
650
+ }
651
+ pathRow.events.push({
652
+ at: entry.receivedAt || null,
653
+ eventType,
654
+ page: entry.page || entry.landingPath || null,
655
+ ctaId: entry.ctaId || null,
656
+ });
657
+ if ((entry.page || entry.landingPath) && !pathRow.pages.includes(entry.page || entry.landingPath)) {
658
+ pathRow.pages.push(entry.page || entry.landingPath);
659
+ }
660
+ if (eventType === 'checkout_start' || eventType === 'checkout_bootstrap') {
661
+ pathRow.checkoutStarts += 1;
662
+ }
663
+ visitorPaths.set(visitorKey, pathRow);
664
+ }
665
+
666
+ if (eventType === 'landing_page_view') {
667
+ externalPageViews += 1;
668
+ incrementCounter(pageViewsBySource, entry.source);
669
+ incrementCounter(pageViewsByPath, entry.page);
670
+ incrementCounter(pageViewsByTrafficChannel, entry.trafficChannel);
671
+ }
672
+
673
+ if (isMarketingClickEvent(eventType)) {
674
+ externalCtaClicks += 1;
675
+ }
676
+
677
+ if (eventType === 'checkout_start' || eventType === 'checkout_bootstrap') {
678
+ externalCheckoutStarts += 1;
679
+ incrementCounter(checkoutStartsBySource, entry.source);
680
+ incrementCounter(checkoutStartsByTrafficChannel, entry.trafficChannel);
681
+ const starterKey = pickFirstText(
682
+ entry.acquisitionId,
683
+ entry.visitorId,
684
+ entry.sessionId,
685
+ entry.installId,
686
+ entry.traceId
687
+ );
688
+ if (starterKey) externalCheckoutStarters.add(starterKey);
689
+ }
690
+
691
+ if (eventType === 'checkout_cancelled' || eventType === 'checkout_abandoned' || eventType === 'reason_not_buying') {
692
+ externalBuyerLossSignals += 1;
693
+ incrementCounter(buyerLossReasons, entry.reasonCode);
694
+ }
695
+ }
696
+
697
+ const topPath = getTopCounterEntry(pageViewsByPath);
698
+ const topSource = getTopCounterEntry(pageViewsBySource);
699
+ const topTrafficChannel = getTopCounterEntry(pageViewsByTrafficChannel);
700
+ const topExclusionReason = getTopCounterEntry(byExclusionReason);
701
+
702
+ return {
703
+ rawEvents: events.length,
704
+ externalEvents,
705
+ excludedEvents,
706
+ internalEvents: byAudience.internal || 0,
707
+ testEvents: byAudience.test || 0,
708
+ botEvents: byAudience.bot || 0,
709
+ exclusionRate: safeRate(excludedEvents, events.length),
710
+ byAudience,
711
+ byExclusionReason,
712
+ topExclusionReason: topExclusionReason ? { key: topExclusionReason[0], count: topExclusionReason[1] } : null,
713
+ external: {
714
+ totalEvents: externalWebEvents,
715
+ uniqueVisitors: externalVisitors.size,
716
+ uniqueSessions: externalSessions.size,
717
+ uniqueCheckoutStarters: externalCheckoutStarters.size,
718
+ pageViews: externalPageViews,
719
+ ctaClicks: externalCtaClicks,
720
+ checkoutStarts: externalCheckoutStarts,
721
+ buyerLossSignals: externalBuyerLossSignals,
722
+ pageViewToCheckoutRate: safeRate(externalCheckoutStarts, externalPageViews),
723
+ visitorToCheckoutRate: safeRate(externalCheckoutStarts, externalVisitors.size),
724
+ bySource: pageViewsBySource,
725
+ byPath: pageViewsByPath,
726
+ byTrafficChannel: pageViewsByTrafficChannel,
727
+ checkoutStartsBySource,
728
+ checkoutStartsByTrafficChannel,
729
+ buyerLossReasons,
730
+ topSource: topSource ? { key: topSource[0], count: topSource[1] } : null,
731
+ topPath: topPath ? { key: topPath[0], count: topPath[1] } : null,
732
+ topTrafficChannel: topTrafficChannel ? { key: topTrafficChannel[0], count: topTrafficChannel[1] } : null,
733
+ visitorPaths: Array.from(visitorPaths.values())
734
+ .sort((a, b) => String(a.firstSeenAt || '').localeCompare(String(b.firstSeenAt || '')))
735
+ .slice(0, 50),
736
+ },
737
+ verdict: externalEvents > 0
738
+ ? (excludedEvents > externalEvents ? 'polluted_but_usable' : 'usable')
739
+ : (events.length > 0 ? 'polluted_no_external_signal' : 'missing'),
740
+ };
741
+ }
742
+
438
743
  function getTelemetrySummary(feedbackDir, options = {}) {
439
744
  const analyticsWindow = resolveAnalyticsWindow(options);
440
745
  const telemetryLoadOptions = analyticsWindow.bounded
@@ -445,6 +750,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
445
750
  analyticsWindow,
446
751
  (entry) => entry && (entry.receivedAt || entry.timestamp)
447
752
  );
753
+ const trafficQuality = summarizeExternalTrafficQuality(events);
448
754
  const byClientType = {};
449
755
  const byEventType = {};
450
756
  const webVisitors = new Set();
@@ -788,6 +1094,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
788
1094
  window: serializeAnalyticsWindow(analyticsWindow),
789
1095
  totalEvents: events.length,
790
1096
  latestSeenAt,
1097
+ trafficQuality,
791
1098
  byClientType,
792
1099
  byEventType,
793
1100
  conversionFunnel: {
@@ -963,6 +1270,8 @@ function getTelemetryAnalytics(feedbackDir, options = {}) {
963
1270
  window: summary.window,
964
1271
  totalEvents: summary.totalEvents,
965
1272
  latestSeenAt: summary.latestSeenAt,
1273
+ trafficQuality: summary.trafficQuality,
1274
+ qualified: summary.trafficQuality.external,
966
1275
  byClientType: summary.byClientType,
967
1276
  byEventType: summary.byEventType,
968
1277
  conversionFunnel: summary.conversionFunnel,
@@ -1142,6 +1451,7 @@ const appendTelemetryPing = appendTelemetryEvent;
1142
1451
  module.exports = {
1143
1452
  TELEMETRY_FILE_NAME,
1144
1453
  sanitizeTelemetryPayload,
1454
+ classifyTelemetryAudience,
1145
1455
  appendTelemetryPing,
1146
1456
  appendTelemetryEvent,
1147
1457
  getTelemetrySourceDiagnostics,
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ const STAGE_ORDER = [
4
+ 'landing',
5
+ 'pricing',
6
+ 'checkout_viewed',
7
+ 'email_submitted',
8
+ 'stripe_redirect',
9
+ 'purchase',
10
+ ];
11
+
12
+ function parseTimestamp(row) {
13
+ const raw = row && (row.timestamp || row.receivedAt || row.ts);
14
+ const ms = raw ? Date.parse(raw) : NaN;
15
+ return Number.isFinite(ms) ? ms : null;
16
+ }
17
+
18
+ function pickText(...values) {
19
+ for (const value of values) {
20
+ const text = String(value || '').trim();
21
+ if (text) return text;
22
+ }
23
+ return '';
24
+ }
25
+
26
+ function extractSessionId(row) {
27
+ return pickText(
28
+ row.sessionId,
29
+ row.visitorSessionId,
30
+ row.visitor_session_id,
31
+ row.attribution && row.attribution.sessionId,
32
+ row.metadata && row.metadata.sessionId,
33
+ row.stripeSessionId,
34
+ row.traceId,
35
+ row.acquisitionId,
36
+ );
37
+ }
38
+
39
+ function extractVisitorId(row) {
40
+ return pickText(
41
+ row.visitorId,
42
+ row.visitor_id,
43
+ row.installId,
44
+ row.attribution && row.attribution.visitorId,
45
+ row.metadata && row.metadata.visitorId,
46
+ );
47
+ }
48
+
49
+ function extractPath(row) {
50
+ return pickText(
51
+ row.path,
52
+ row.page,
53
+ row.landingPath,
54
+ row.landing_path,
55
+ row.attribution && row.attribution.landingPath,
56
+ );
57
+ }
58
+
59
+ function classifyStage(row) {
60
+ const event = String(row.eventType || row.event || '').toLowerCase();
61
+ const stage = String(row.stage || '').toLowerCase();
62
+ const path = extractPath(row).toLowerCase();
63
+ const cta = String(row.ctaId || row.cta_id || '').toLowerCase();
64
+
65
+ if (/purchase|paid|checkout\.session\.completed/.test(event) || stage === 'purchase') return 'purchase';
66
+ if (/stripe_redirect|stripe redirect|redirect started/.test(event) || stage === 'stripe_redirect') return 'stripe_redirect';
67
+ if (/email_submitted|email submitted|checkout_interstitial_cta_clicked/.test(event)) return 'email_submitted';
68
+ if (/checkout/.test(path) || /checkout.*view|checkout pro viewed/.test(event)) return 'checkout_viewed';
69
+ if (/pricing/.test(path) || /pricing/.test(cta) || /pricing_cta/.test(event)) return 'pricing';
70
+ if (/landing|page_view|landing_page_view|discovery/.test(event) || stage === 'discovery') return 'landing';
71
+ return null;
72
+ }
73
+
74
+ function stageRank(stage) {
75
+ const index = STAGE_ORDER.indexOf(stage);
76
+ return index === -1 ? -1 : index;
77
+ }
78
+
79
+ function nextMissingStage(maxStage) {
80
+ const index = stageRank(maxStage);
81
+ if (index < 0) return 'unknown';
82
+ if (maxStage === 'purchase') return 'converted';
83
+ return STAGE_ORDER[index + 1] || 'unknown';
84
+ }
85
+
86
+ function buildVisitorJourneySummary({ telemetryRows = [], funnelRows = [], limit = 100 } = {}) {
87
+ const sessions = new Map();
88
+ const stageCounts = Object.fromEntries(STAGE_ORDER.map((stage) => [stage, 0]));
89
+
90
+ function ingest(row, source) {
91
+ const ts = parseTimestamp(row);
92
+ if (ts == null) return;
93
+ const sessionId = extractSessionId(row) || `anonymous:${extractVisitorId(row) || ts}`;
94
+ const visitorId = extractVisitorId(row) || null;
95
+ const stage = classifyStage(row);
96
+ const path = extractPath(row);
97
+ const existing = sessions.get(sessionId) || {
98
+ sessionId,
99
+ visitorId,
100
+ firstSeen: new Date(ts).toISOString(),
101
+ lastSeen: new Date(ts).toISOString(),
102
+ maxStage: null,
103
+ dropoffStage: 'unknown',
104
+ events: [],
105
+ paths: [],
106
+ ctas: [],
107
+ sources: [],
108
+ visitorType: row.visitorType || null,
109
+ utm: {},
110
+ referrerHost: pickText(row.referrerHost, row.referrer_host, row.referrer),
111
+ };
112
+
113
+ existing.visitorId = existing.visitorId || visitorId;
114
+ existing.firstSeen = new Date(Math.min(Date.parse(existing.firstSeen), ts)).toISOString();
115
+ existing.lastSeen = new Date(Math.max(Date.parse(existing.lastSeen), ts)).toISOString();
116
+ existing.sources.push(source);
117
+ if (stage) {
118
+ stageCounts[stage] += 1;
119
+ if (stageRank(stage) > stageRank(existing.maxStage)) existing.maxStage = stage;
120
+ }
121
+ if (path && !existing.paths.includes(path)) existing.paths.push(path);
122
+ const cta = pickText(row.ctaId, row.cta_id);
123
+ if (cta && !existing.ctas.includes(cta)) existing.ctas.push(cta);
124
+ for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
125
+ const value = pickText(row[key], row.attribution && row.attribution[key]);
126
+ if (value && !existing.utm[key]) existing.utm[key] = value;
127
+ }
128
+ existing.events.push({
129
+ timestamp: new Date(ts).toISOString(),
130
+ source,
131
+ eventType: pickText(row.eventType, row.event, row.stage, 'unknown'),
132
+ stage,
133
+ path: path || null,
134
+ ctaId: cta || null,
135
+ });
136
+ sessions.set(sessionId, existing);
137
+ }
138
+
139
+ for (const row of telemetryRows) ingest(row, 'telemetry');
140
+ for (const row of funnelRows) ingest(row, 'funnel');
141
+
142
+ const journeys = [...sessions.values()].map((session) => {
143
+ session.sources = [...new Set(session.sources)];
144
+ session.events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
145
+ session.maxStage = session.maxStage || 'unknown';
146
+ session.dropoffStage = nextMissingStage(session.maxStage);
147
+ session.eventCount = session.events.length;
148
+ return session;
149
+ }).sort((a, b) => Date.parse(b.lastSeen) - Date.parse(a.lastSeen));
150
+
151
+ const dropoffCounts = {};
152
+ for (const journey of journeys) {
153
+ dropoffCounts[journey.dropoffStage] = (dropoffCounts[journey.dropoffStage] || 0) + 1;
154
+ }
155
+
156
+ return {
157
+ generatedAt: new Date().toISOString(),
158
+ sessionCount: journeys.length,
159
+ stageCounts,
160
+ dropoffCounts,
161
+ journeys: journeys.slice(0, Math.max(1, Math.min(Number(limit) || 100, 500))),
162
+ truncated: journeys.length > Math.max(1, Math.min(Number(limit) || 100, 500)),
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ STAGE_ORDER,
168
+ buildVisitorJourneySummary,
169
+ classifyStage,
170
+ extractSessionId,
171
+ extractVisitorId,
172
+ };