routeflow-browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # @routeflow/browser
2
+
3
+ Browser SDK for Routeflow - Frontend-to-backend correlation and external dependency tracking.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @routeflow/browser
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### Basic Setup (Correlation Only)
14
+
15
+ Add correlation headers to all same-origin API calls with one line:
16
+
17
+ ```typescript
18
+ import { initRouteflow } from "@routeflow/browser";
19
+
20
+ initRouteflow();
21
+ ```
22
+
23
+ That's it! Now all same-origin `fetch()` calls will automatically include:
24
+ - `x-routeflow-trace-id` - Unique session ID (persisted across page navigations)
25
+ - `x-routeflow-frontend-route` - Current frontend route (e.g., `/checkout`)
26
+
27
+ ## How It Works
28
+
29
+ ### Automatic Correlation Headers
30
+
31
+ The SDK patches `window.fetch` to inject correlation headers into **same-origin requests only**:
32
+
33
+ ```javascript
34
+ // Your code
35
+ fetch("/api/orders", { method: "POST" });
36
+
37
+ // SDK automatically adds headers:
38
+ // x-routeflow-trace-id: 550e8400-e29b-41d4-a716-446655440000
39
+ // x-routeflow-frontend-route: /checkout
40
+ ```
41
+
42
+ - **Trace ID**: Generated once per browser tab session, stored in `sessionStorage`
43
+ - **Frontend Route**: Tracks `location.pathname` (no query strings) by monitoring history API
44
+ - **Framework agnostic**: Works with React, Vue, Next.js, vanilla JS, etc.
45
+ - **No CORS issues**: Headers only added to same-origin requests
46
+
47
+ ### External Dependency Tracking (Optional)
48
+
49
+ Track calls to external APIs (Stripe, S3, etc.) and correlate them with frontend behavior:
50
+
51
+ ```typescript
52
+ initRouteflow({
53
+ ingestKey: "your-routeflow-ingest-key",
54
+ trackExternal: true,
55
+ externalAllowlist: [
56
+ "api.stripe.com",
57
+ "*.amazonaws.com",
58
+ "api.example.com"
59
+ ]
60
+ });
61
+ ```
62
+
63
+ The SDK will:
64
+ 1. Track timing and outcomes of cross-origin `fetch()` calls
65
+ 2. Send telemetry events to Routeflow backend
66
+ 3. Only track hosts in the allowlist (for privacy and performance)
67
+
68
+ **Privacy guarantee**: Only the **hostname** is captured (no URLs, paths, query strings, headers, or bodies).
69
+
70
+ ## Configuration Options
71
+
72
+ ```typescript
73
+ interface RouteflowInitOptions {
74
+ // Optional ingest key for sending external spans
75
+ ingestKey?: string;
76
+
77
+ // Enable/disable SDK (default: true)
78
+ enabled?: boolean;
79
+
80
+ // Environment name (default: "production")
81
+ environment?: "production" | "staging" | "preview" | "development";
82
+
83
+ // Sample rate for events 0.0-1.0 (default: 1.0)
84
+ sampleRate?: number;
85
+
86
+ // Enable tracking of external dependency calls (default: false)
87
+ trackExternal?: boolean;
88
+
89
+ // Allowlist of hostnames for external tracking (default: [])
90
+ // Supports wildcards: "*.amazonaws.com"
91
+ externalAllowlist?: string[];
92
+
93
+ // Flush interval in milliseconds (default: 1000)
94
+ flushIntervalMs?: number;
95
+
96
+ // Maximum queue size before dropping events (default: 5000)
97
+ maxQueueSize?: number;
98
+ }
99
+ ```
100
+
101
+ ## Examples
102
+
103
+ ### With Environment
104
+
105
+ ```typescript
106
+ initRouteflow({
107
+ environment: process.env.NODE_ENV === "production" ? "production" : "development"
108
+ });
109
+ ```
110
+
111
+ ### With Runtime Ingest Key
112
+
113
+ If you inject the key at build time or via a script tag:
114
+
115
+ ```html
116
+ <script>
117
+ window.__ROUTEFLOW_INGEST_KEY__ = "your-ingest-key-from-server";
118
+ </script>
119
+ <script src="/app.js"></script>
120
+ ```
121
+
122
+ ```typescript
123
+ // SDK will automatically use window.__ROUTEFLOW_INGEST_KEY__
124
+ initRouteflow({
125
+ trackExternal: true,
126
+ externalAllowlist: ["api.stripe.com"]
127
+ });
128
+ ```
129
+
130
+ ### With Code Version
131
+
132
+ Track which version of your frontend code generated events:
133
+
134
+ ```html
135
+ <script>
136
+ window.__ROUTEFLOW_CODE_VERSION__ = "abc123def"; // Git SHA
137
+ </script>
138
+ ```
139
+
140
+ ### Sampling for High Traffic
141
+
142
+ Only track 10% of sessions:
143
+
144
+ ```typescript
145
+ initRouteflow({
146
+ trackExternal: true,
147
+ externalAllowlist: ["api.stripe.com"],
148
+ sampleRate: 0.1
149
+ });
150
+ ```
151
+
152
+ ## Stats and Debugging
153
+
154
+ Check SDK status:
155
+
156
+ ```typescript
157
+ import { getRouteflowStats } from "@routeflow/browser";
158
+
159
+ const stats = getRouteflowStats();
160
+ console.log(stats);
161
+ // {
162
+ // enabled: true,
163
+ // trace_id: "550e8400-e29b-41d4-a716-446655440000",
164
+ // current_route: "/checkout",
165
+ // events_queued: 0,
166
+ // events_sent: 42,
167
+ // events_dropped: 0,
168
+ // events_failed: 0,
169
+ // external_tracking_enabled: true,
170
+ // has_ingest_key: true
171
+ // }
172
+ ```
173
+
174
+ ## Privacy & Security
175
+
176
+ ### What We Collect
177
+
178
+ **For correlation (always)**:
179
+ - Trace ID (random UUID, session-scoped)
180
+ - Frontend route pathname (no query strings)
181
+
182
+ **For external spans (opt-in)**:
183
+ - Target hostname only (e.g., `api.stripe.com`)
184
+ - HTTP method (e.g., `POST`)
185
+ - Duration in milliseconds
186
+ - Outcome (`success`, `failure`, `unknown`)
187
+ - Status code (if available)
188
+
189
+ ### What We DON'T Collect
190
+
191
+ - ❌ Full URLs
192
+ - ❌ URL paths or query parameters
193
+ - ❌ Request/response headers
194
+ - ❌ Request/response bodies
195
+ - ❌ Cookies
196
+ - ❌ User IDs or personal information
197
+ - ❌ IP addresses
198
+
199
+ ### CORS Considerations
200
+
201
+ - Correlation headers are **only added to same-origin requests**
202
+ - External tracking **never modifies** cross-origin requests
203
+ - No risk of triggering CORS preflight checks
204
+
205
+ ## Requirements
206
+
207
+ - Modern browsers with `fetch` support
208
+ - TypeScript 5.0+ (for development)
209
+ - No runtime dependencies
210
+
211
+ ## Advanced: Custom Backend URL
212
+
213
+ The SDK is hardcoded to send events to:
214
+ ```
215
+ https://routeflow-backend-production.up.railway.app/v1/ingest
216
+ ```
217
+
218
+ This is intentional to simplify setup. Contact support if you need a custom backend URL.
219
+
220
+ ## License
221
+
222
+ MIT
223
+ # routeflow-browser
@@ -0,0 +1,8 @@
1
+ /**
2
+ * HTTP client for sending events to Routeflow backend.
3
+ */
4
+ import { IngestPayload } from "./types";
5
+ /**
6
+ * Send events to Routeflow backend.
7
+ */
8
+ export declare function sendEvents(payload: IngestPayload, ingestKey: string): Promise<boolean>;
package/dist/client.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * HTTP client for sending events to Routeflow backend.
3
+ */
4
+ // Use localhost for development, production URL for deployed environments
5
+ const BACKEND_URL = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
6
+ ? "http://127.0.0.1:5004"
7
+ : "https://routeflow-backend-production.up.railway.app";
8
+ const INGEST_ENDPOINT = "/v1/ingest";
9
+ /**
10
+ * Send events to Routeflow backend.
11
+ */
12
+ export async function sendEvents(payload, ingestKey) {
13
+ try {
14
+ const url = `${BACKEND_URL}${INGEST_ENDPOINT}`;
15
+ const body = JSON.stringify(payload);
16
+ // Use fetch with keepalive for best reliability
17
+ const success = await fetch(url, {
18
+ method: "POST",
19
+ headers: {
20
+ Authorization: `Bearer ${ingestKey}`,
21
+ "Content-Type": "application/json",
22
+ },
23
+ body: body,
24
+ keepalive: true,
25
+ }).then((res) => res.ok, () => false);
26
+ return success;
27
+ }
28
+ catch (error) {
29
+ // Never throw - just return failure
30
+ return false;
31
+ }
32
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * External dependency span tracking.
3
+ */
4
+ import { Environment } from "./types";
5
+ interface ExternalSpanConfig {
6
+ environment: Environment;
7
+ allowlist: string[];
8
+ ingestKey?: string;
9
+ maxQueueSize: number;
10
+ flushIntervalMs: number;
11
+ sampleRate: number;
12
+ }
13
+ interface SpanStats {
14
+ queued: number;
15
+ sent: number;
16
+ dropped: number;
17
+ failed: number;
18
+ }
19
+ /**
20
+ * Initialize external span tracking.
21
+ */
22
+ export declare function initExternalSpanTracking(config: ExternalSpanConfig): void;
23
+ /**
24
+ * Track an external fetch call.
25
+ */
26
+ export declare function trackExternalFetch(url: string, init: RequestInit | undefined, startTime: number, endTime: number, response: Response | null, error: Error | null): void;
27
+ /**
28
+ * Get external span stats.
29
+ */
30
+ export declare function getExternalSpanStats(): SpanStats;
31
+ /**
32
+ * Get queue size.
33
+ */
34
+ export declare function getExternalSpanQueueSize(): number;
35
+ /**
36
+ * Stop external span tracking.
37
+ */
38
+ export declare function stopExternalSpanTracking(): void;
39
+ export {};
@@ -0,0 +1,233 @@
1
+ /**
2
+ * External dependency span tracking.
3
+ */
4
+ import { extractHostname, isHostAllowed, validateTargetHost, } from "./validate";
5
+ import { getCurrentFrontendRoute } from "./route";
6
+ import { getOrCreateSessionId, generateTraceId } from "./trace";
7
+ import { sendEvents } from "./client";
8
+ class ExternalSpanTracker {
9
+ constructor(config) {
10
+ this.queue = [];
11
+ this.stats = {
12
+ queued: 0,
13
+ sent: 0,
14
+ dropped: 0,
15
+ failed: 0,
16
+ };
17
+ this.flushTimer = null;
18
+ this.SDK_NAME = "@routeflow/browser";
19
+ this.SDK_VERSION = "0.1.0"; // TODO: sync with package.json
20
+ this.config = config;
21
+ this.startFlushTimer();
22
+ }
23
+ /**
24
+ * Track an external fetch call.
25
+ */
26
+ trackFetch(url, init, startTime, endTime, response, error) {
27
+ try {
28
+ // Don't track requests to the Routeflow ingest endpoint (prevents feedback loop)
29
+ if (url.includes('/v1/ingest')) {
30
+ return;
31
+ }
32
+ // Sample rate check
33
+ if (Math.random() > this.config.sampleRate) {
34
+ return;
35
+ }
36
+ // Extract hostname
37
+ const hostname = extractHostname(url);
38
+ if (!hostname) {
39
+ return;
40
+ }
41
+ // Check allowlist
42
+ if (!isHostAllowed(hostname, this.config.allowlist)) {
43
+ return;
44
+ }
45
+ // Validate hostname (no scheme, path, query)
46
+ if (!validateTargetHost(hostname)) {
47
+ return;
48
+ }
49
+ // Determine method
50
+ const method = (init?.method || "GET").toUpperCase();
51
+ // Calculate duration
52
+ const duration_ms = Math.round(endTime - startTime);
53
+ // Determine outcome and status code
54
+ let outcome = "unknown";
55
+ let status_code;
56
+ if (error) {
57
+ outcome = "failure";
58
+ }
59
+ else if (response) {
60
+ status_code = response.status;
61
+ outcome = response.ok ? "success" : "failure";
62
+ }
63
+ // Extract trace_id from request headers (it was added by addCorrelationHeaders)
64
+ // This ensures the frontend span has the same trace_id as the backend received
65
+ let trace_id;
66
+ const session_id = getOrCreateSessionId();
67
+ if (init && init.headers) {
68
+ const headers = new Headers(init.headers);
69
+ trace_id = headers.get("x-routeflow-trace-id") || undefined;
70
+ }
71
+ // Fallback: generate new trace_id if not found (shouldn't happen)
72
+ if (!trace_id) {
73
+ trace_id = generateTraceId();
74
+ }
75
+ const frontend_route = getCurrentFrontendRoute();
76
+ // Build event
77
+ const event = {
78
+ type: "frontend_external_request_observed",
79
+ timestamp: new Date(startTime).toISOString(),
80
+ trace_id,
81
+ session_id,
82
+ environment: this.config.environment,
83
+ frontend_route,
84
+ target_host: hostname,
85
+ method,
86
+ duration_ms,
87
+ outcome,
88
+ sdk: {
89
+ name: this.SDK_NAME,
90
+ version: this.SDK_VERSION,
91
+ },
92
+ };
93
+ // Add optional fields
94
+ if (status_code !== undefined) {
95
+ event.status_code = status_code;
96
+ }
97
+ // Add code version if available
98
+ if (typeof window !== "undefined" && window.__ROUTEFLOW_CODE_VERSION__) {
99
+ event.code_version = window.__ROUTEFLOW_CODE_VERSION__;
100
+ }
101
+ // Queue event
102
+ this.queueEvent(event);
103
+ }
104
+ catch {
105
+ // Never throw
106
+ }
107
+ }
108
+ /**
109
+ * Queue an event for sending.
110
+ */
111
+ queueEvent(event) {
112
+ if (this.queue.length >= this.config.maxQueueSize) {
113
+ this.stats.dropped++;
114
+ return;
115
+ }
116
+ this.queue.push(event);
117
+ this.stats.queued++;
118
+ // Flush if queue is getting large
119
+ if (this.queue.length >= 50) {
120
+ this.flush();
121
+ }
122
+ }
123
+ /**
124
+ * Flush queued events to backend.
125
+ */
126
+ async flush() {
127
+ if (this.queue.length === 0) {
128
+ return;
129
+ }
130
+ // Check for ingest key
131
+ if (!this.config.ingestKey) {
132
+ // Drop events silently
133
+ this.stats.dropped += this.queue.length;
134
+ this.queue = [];
135
+ return;
136
+ }
137
+ // Take events from queue
138
+ const events = this.queue.splice(0, this.queue.length);
139
+ // Build payload
140
+ const payload = {
141
+ sent_at: new Date().toISOString(),
142
+ events,
143
+ };
144
+ // Send to backend
145
+ const success = await sendEvents(payload, this.config.ingestKey);
146
+ if (success) {
147
+ this.stats.sent += events.length;
148
+ }
149
+ else {
150
+ this.stats.failed += events.length;
151
+ }
152
+ }
153
+ /**
154
+ * Start flush timer.
155
+ */
156
+ startFlushTimer() {
157
+ if (this.flushTimer !== null) {
158
+ return;
159
+ }
160
+ this.flushTimer = window.setInterval(() => {
161
+ this.flush();
162
+ }, this.config.flushIntervalMs);
163
+ }
164
+ /**
165
+ * Stop flush timer.
166
+ */
167
+ stop() {
168
+ if (this.flushTimer !== null) {
169
+ clearInterval(this.flushTimer);
170
+ this.flushTimer = null;
171
+ }
172
+ // Final flush
173
+ this.flush();
174
+ }
175
+ /**
176
+ * Get current stats.
177
+ */
178
+ getStats() {
179
+ return { ...this.stats };
180
+ }
181
+ /**
182
+ * Get queue size.
183
+ */
184
+ getQueueSize() {
185
+ return this.queue.length;
186
+ }
187
+ }
188
+ let tracker = null;
189
+ /**
190
+ * Initialize external span tracking.
191
+ */
192
+ export function initExternalSpanTracking(config) {
193
+ if (tracker) {
194
+ tracker.stop();
195
+ }
196
+ tracker = new ExternalSpanTracker(config);
197
+ }
198
+ /**
199
+ * Track an external fetch call.
200
+ */
201
+ export function trackExternalFetch(url, init, startTime, endTime, response, error) {
202
+ if (!tracker) {
203
+ return;
204
+ }
205
+ tracker.trackFetch(url, init, startTime, endTime, response, error);
206
+ }
207
+ /**
208
+ * Get external span stats.
209
+ */
210
+ export function getExternalSpanStats() {
211
+ if (!tracker) {
212
+ return { queued: 0, sent: 0, dropped: 0, failed: 0 };
213
+ }
214
+ return tracker.getStats();
215
+ }
216
+ /**
217
+ * Get queue size.
218
+ */
219
+ export function getExternalSpanQueueSize() {
220
+ if (!tracker) {
221
+ return 0;
222
+ }
223
+ return tracker.getQueueSize();
224
+ }
225
+ /**
226
+ * Stop external span tracking.
227
+ */
228
+ export function stopExternalSpanTracking() {
229
+ if (tracker) {
230
+ tracker.stop();
231
+ tracker = null;
232
+ }
233
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Fetch patching for correlation headers and external span tracking.
3
+ */
4
+ /**
5
+ * Patch window.fetch to add correlation headers.
6
+ */
7
+ export declare function patchFetch(enableExternalTracking: boolean): void;
8
+ /**
9
+ * Restore original fetch.
10
+ */
11
+ export declare function unpatchFetch(): void;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Fetch patching for correlation headers and external span tracking.
3
+ */
4
+ import { isSameOrigin } from "./validate";
5
+ import { getOrCreateSessionId, generateTraceId } from "./trace";
6
+ import { getCurrentFrontendRoute } from "./route";
7
+ import { trackExternalFetch } from "./external_spans";
8
+ const TRACE_ID_HEADER = "x-routeflow-trace-id";
9
+ const SESSION_ID_HEADER = "x-routeflow-session-id";
10
+ const FRONTEND_ROUTE_HEADER = "x-routeflow-frontend-route";
11
+ let originalFetch;
12
+ let isPatched = false;
13
+ let trackExternal = false;
14
+ /**
15
+ * Patch window.fetch to add correlation headers.
16
+ */
17
+ export function patchFetch(enableExternalTracking) {
18
+ if (isPatched) {
19
+ return;
20
+ }
21
+ if (typeof window === "undefined" || typeof window.fetch !== "function") {
22
+ return;
23
+ }
24
+ originalFetch = window.fetch;
25
+ trackExternal = enableExternalTracking;
26
+ window.fetch = async function patchedFetch(input, init) {
27
+ // Convert input to URL string
28
+ const url = typeof input === "string"
29
+ ? input
30
+ : input instanceof URL
31
+ ? input.href
32
+ : input.url;
33
+ const sameOrigin = isSameOrigin(url);
34
+ // Add correlation headers for same-origin requests
35
+ // For external requests, we'll add headers in trackAndFetch if tracking is enabled
36
+ if (sameOrigin) {
37
+ init = addCorrelationHeaders(input, init);
38
+ }
39
+ // Track external spans if enabled (will also add correlation headers)
40
+ if (trackExternal && !sameOrigin) {
41
+ return trackAndFetch(url, init);
42
+ }
43
+ // Regular fetch
44
+ return originalFetch(input, init);
45
+ };
46
+ isPatched = true;
47
+ }
48
+ /**
49
+ * Add correlation headers to same-origin requests.
50
+ */
51
+ function addCorrelationHeaders(input, init) {
52
+ // Get or create headers
53
+ const headers = new Headers(init?.headers);
54
+ // Add trace ID if not already present (unique per request)
55
+ if (!headers.has(TRACE_ID_HEADER)) {
56
+ const traceId = generateTraceId();
57
+ headers.set(TRACE_ID_HEADER, traceId);
58
+ }
59
+ // Add session ID if not already present (persists across requests)
60
+ if (!headers.has(SESSION_ID_HEADER)) {
61
+ const sessionId = getOrCreateSessionId();
62
+ headers.set(SESSION_ID_HEADER, sessionId);
63
+ }
64
+ // Add frontend route if not already present
65
+ if (!headers.has(FRONTEND_ROUTE_HEADER)) {
66
+ const route = getCurrentFrontendRoute();
67
+ headers.set(FRONTEND_ROUTE_HEADER, route);
68
+ }
69
+ return {
70
+ ...init,
71
+ headers,
72
+ };
73
+ }
74
+ /**
75
+ * Track external fetch call and execute it.
76
+ * Also adds correlation headers to tracked external requests.
77
+ */
78
+ async function trackAndFetch(url, init) {
79
+ const startTime = performance.now();
80
+ let response = null;
81
+ let error = null;
82
+ // Add correlation headers to tracked external requests
83
+ // This allows backends in your infrastructure to correlate requests
84
+ init = addCorrelationHeaders(url, init);
85
+ try {
86
+ response = await originalFetch(url, init);
87
+ return response;
88
+ }
89
+ catch (e) {
90
+ error = e;
91
+ throw e;
92
+ }
93
+ finally {
94
+ const endTime = performance.now();
95
+ trackExternalFetch(url, init, startTime, endTime, response, error);
96
+ }
97
+ }
98
+ /**
99
+ * Restore original fetch.
100
+ */
101
+ export function unpatchFetch() {
102
+ if (!isPatched) {
103
+ return;
104
+ }
105
+ if (typeof window !== "undefined" && originalFetch) {
106
+ window.fetch = originalFetch;
107
+ }
108
+ isPatched = false;
109
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Routeflow Browser SDK
3
+ *
4
+ * Frontend-to-backend correlation and external dependency tracking.
5
+ */
6
+ export { initRouteflow, getRouteflowStats } from "./init";
7
+ export type { RouteflowInitOptions, RouteflowStats } from "./types";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Routeflow Browser SDK
3
+ *
4
+ * Frontend-to-backend correlation and external dependency tracking.
5
+ */
6
+ export { initRouteflow, getRouteflowStats } from "./init";
package/dist/init.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Initialization and configuration for Routeflow Browser SDK.
3
+ */
4
+ import { RouteflowInitOptions, RouteflowStats } from "./types";
5
+ /**
6
+ * Initialize Routeflow Browser SDK.
7
+ */
8
+ export declare function initRouteflow(opts?: RouteflowInitOptions): void;
9
+ /**
10
+ * Get Routeflow stats.
11
+ */
12
+ export declare function getRouteflowStats(): RouteflowStats;
13
+ /**
14
+ * Shutdown Routeflow (for cleanup).
15
+ */
16
+ export declare function shutdownRouteflow(): void;
package/dist/init.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Initialization and configuration for Routeflow Browser SDK.
3
+ */
4
+ import { initRouteTracking, getCurrentFrontendRoute } from "./route";
5
+ import { getOrCreateSessionId } from "./trace";
6
+ import { patchFetch, unpatchFetch } from "./fetch_patch";
7
+ import { initExternalSpanTracking, getExternalSpanStats, getExternalSpanQueueSize, stopExternalSpanTracking, } from "./external_spans";
8
+ let isInitialized = false;
9
+ let config = {
10
+ ingestKey: "",
11
+ enabled: true,
12
+ environment: "production",
13
+ sampleRate: 1.0,
14
+ trackExternal: false,
15
+ externalAllowlist: [],
16
+ flushIntervalMs: 1000,
17
+ maxQueueSize: 5000,
18
+ };
19
+ /**
20
+ * Detect environment from deployment platform environment variables.
21
+ * Supports Vercel, Netlify, CloudFlare Pages, and other common platforms.
22
+ */
23
+ function detectFrontendEnvironment() {
24
+ if (typeof process === "undefined" || !process.env) {
25
+ return null;
26
+ }
27
+ // Vercel
28
+ // VERCEL_ENV: "production" | "preview" | "development"
29
+ if (process.env.VERCEL_ENV) {
30
+ const vercelEnv = process.env.VERCEL_ENV;
31
+ if (vercelEnv === "production" || vercelEnv === "preview" || vercelEnv === "development") {
32
+ return vercelEnv;
33
+ }
34
+ // Map "staging" if custom
35
+ if (vercelEnv === "staging")
36
+ return "staging";
37
+ }
38
+ // Netlify
39
+ // CONTEXT: "production" | "deploy-preview" | "branch-deploy" | "dev"
40
+ if (process.env.CONTEXT) {
41
+ const netlifyContext = process.env.CONTEXT;
42
+ if (netlifyContext === "production")
43
+ return "production";
44
+ if (netlifyContext === "deploy-preview")
45
+ return "preview";
46
+ if (netlifyContext === "branch-deploy")
47
+ return "staging";
48
+ if (netlifyContext === "dev")
49
+ return "development";
50
+ }
51
+ // Cloudflare Pages
52
+ // CF_PAGES_BRANCH: "main" or other branch names
53
+ if (process.env.CF_PAGES && process.env.CF_PAGES_BRANCH) {
54
+ const branch = process.env.CF_PAGES_BRANCH;
55
+ if (branch === "main" || branch === "master")
56
+ return "production";
57
+ if (branch.includes("staging"))
58
+ return "staging";
59
+ return "preview";
60
+ }
61
+ // AWS Amplify
62
+ if (process.env.AWS_BRANCH) {
63
+ const branch = process.env.AWS_BRANCH;
64
+ if (branch === "main" || branch === "master")
65
+ return "production";
66
+ if (branch.includes("staging"))
67
+ return "staging";
68
+ return "preview";
69
+ }
70
+ // Generic NODE_ENV fallback
71
+ if (process.env.NODE_ENV === "production")
72
+ return "production";
73
+ if (process.env.NODE_ENV === "development")
74
+ return "development";
75
+ return null;
76
+ }
77
+ /**
78
+ * Initialize Routeflow Browser SDK.
79
+ */
80
+ export function initRouteflow(opts) {
81
+ if (isInitialized) {
82
+ console.warn("Routeflow already initialized");
83
+ return;
84
+ }
85
+ // Detect environment from deployment platform
86
+ const detectedEnvironment = detectFrontendEnvironment();
87
+ // Merge options with defaults
88
+ config = {
89
+ ingestKey: opts?.ingestKey || "",
90
+ enabled: opts?.enabled ?? true,
91
+ environment: opts?.environment || detectedEnvironment || "production",
92
+ sampleRate: opts?.sampleRate ?? 1.0,
93
+ trackExternal: opts?.trackExternal ?? false,
94
+ externalAllowlist: opts?.externalAllowlist || [],
95
+ flushIntervalMs: opts?.flushIntervalMs ?? 1000,
96
+ maxQueueSize: opts?.maxQueueSize ?? 5000,
97
+ };
98
+ // Try to get ingest key from window if not provided
99
+ if (!config.ingestKey && typeof window !== "undefined") {
100
+ config.ingestKey = window.__ROUTEFLOW_INGEST_KEY__ || "";
101
+ }
102
+ // If disabled, skip initialization
103
+ if (!config.enabled) {
104
+ return;
105
+ }
106
+ // Initialize route tracking
107
+ initRouteTracking();
108
+ // Initialize session ID (persists across page navigation within session)
109
+ getOrCreateSessionId();
110
+ // Patch fetch for correlation headers
111
+ patchFetch(config.trackExternal);
112
+ // Initialize external span tracking if enabled
113
+ if (config.trackExternal) {
114
+ initExternalSpanTracking({
115
+ environment: config.environment,
116
+ allowlist: config.externalAllowlist,
117
+ ingestKey: config.ingestKey || undefined,
118
+ maxQueueSize: config.maxQueueSize,
119
+ flushIntervalMs: config.flushIntervalMs,
120
+ sampleRate: config.sampleRate,
121
+ });
122
+ }
123
+ isInitialized = true;
124
+ }
125
+ /**
126
+ * Get Routeflow stats.
127
+ */
128
+ export function getRouteflowStats() {
129
+ const externalStats = getExternalSpanStats();
130
+ return {
131
+ enabled: config.enabled,
132
+ session_id: getOrCreateSessionId(),
133
+ current_route: getCurrentFrontendRoute(),
134
+ events_queued: getExternalSpanQueueSize(),
135
+ events_sent: externalStats.sent,
136
+ events_dropped: externalStats.dropped,
137
+ events_failed: externalStats.failed,
138
+ external_tracking_enabled: config.trackExternal,
139
+ has_ingest_key: !!config.ingestKey,
140
+ };
141
+ }
142
+ /**
143
+ * Shutdown Routeflow (for cleanup).
144
+ */
145
+ export function shutdownRouteflow() {
146
+ if (!isInitialized) {
147
+ return;
148
+ }
149
+ unpatchFetch();
150
+ stopExternalSpanTracking();
151
+ isInitialized = false;
152
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Frontend route tracking (framework-agnostic).
3
+ */
4
+ /**
5
+ * Initialize route tracking by patching history API.
6
+ */
7
+ export declare function initRouteTracking(): void;
8
+ /**
9
+ * Get the current frontend route.
10
+ */
11
+ export declare function getCurrentFrontendRoute(): string;
package/dist/route.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Frontend route tracking (framework-agnostic).
3
+ */
4
+ import { normalizeFrontendRoute } from "./validate";
5
+ let currentRoute = "/";
6
+ /**
7
+ * Initialize route tracking by patching history API.
8
+ */
9
+ export function initRouteTracking() {
10
+ // Set initial route
11
+ updateCurrentRoute();
12
+ // Patch pushState
13
+ const originalPushState = history.pushState;
14
+ history.pushState = function (...args) {
15
+ const result = originalPushState.apply(this, args);
16
+ updateCurrentRoute();
17
+ return result;
18
+ };
19
+ // Patch replaceState
20
+ const originalReplaceState = history.replaceState;
21
+ history.replaceState = function (...args) {
22
+ const result = originalReplaceState.apply(this, args);
23
+ updateCurrentRoute();
24
+ return result;
25
+ };
26
+ // Listen to popstate (back/forward buttons)
27
+ window.addEventListener("popstate", () => {
28
+ updateCurrentRoute();
29
+ });
30
+ }
31
+ /**
32
+ * Update the current route from location.pathname.
33
+ */
34
+ function updateCurrentRoute() {
35
+ currentRoute = normalizeFrontendRoute(window.location.pathname);
36
+ }
37
+ /**
38
+ * Get the current frontend route.
39
+ */
40
+ export function getCurrentFrontendRoute() {
41
+ return currentRoute;
42
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Trace ID and Session ID management for browser sessions.
3
+ */
4
+ /**
5
+ * Get or create session ID for this browser session.
6
+ * Session ID persists across requests within the same session.
7
+ */
8
+ export declare function getOrCreateSessionId(): string;
9
+ /**
10
+ * Generate a unique trace ID for a single request.
11
+ * Trace ID is unique per request, not persisted.
12
+ */
13
+ export declare function generateTraceId(): string;
14
+ /**
15
+ * Get current session ID (without creating).
16
+ */
17
+ export declare function getSessionId(): string | null;
package/dist/trace.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Trace ID and Session ID management for browser sessions.
3
+ */
4
+ const SESSION_ID_KEY = "routeflow_session_id";
5
+ /**
6
+ * Generate a UUID v4.
7
+ */
8
+ function generateUUID() {
9
+ // Use crypto.randomUUID if available (modern browsers)
10
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
11
+ return crypto.randomUUID();
12
+ }
13
+ // Fallback: simple UUID v4 implementation
14
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
15
+ const r = (Math.random() * 16) | 0;
16
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
17
+ return v.toString(16);
18
+ });
19
+ }
20
+ /**
21
+ * Get or create session ID for this browser session.
22
+ * Session ID persists across requests within the same session.
23
+ */
24
+ export function getOrCreateSessionId() {
25
+ try {
26
+ // Try to get existing session ID from sessionStorage
27
+ const existing = sessionStorage.getItem(SESSION_ID_KEY);
28
+ if (existing) {
29
+ return existing;
30
+ }
31
+ // Generate new session ID
32
+ const sessionId = generateUUID();
33
+ sessionStorage.setItem(SESSION_ID_KEY, sessionId);
34
+ return sessionId;
35
+ }
36
+ catch {
37
+ // If sessionStorage fails, generate ephemeral ID
38
+ return generateUUID();
39
+ }
40
+ }
41
+ /**
42
+ * Generate a unique trace ID for a single request.
43
+ * Trace ID is unique per request, not persisted.
44
+ */
45
+ export function generateTraceId() {
46
+ return generateUUID();
47
+ }
48
+ /**
49
+ * Get current session ID (without creating).
50
+ */
51
+ export function getSessionId() {
52
+ try {
53
+ return sessionStorage.getItem(SESSION_ID_KEY);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Type definitions for Routeflow Browser SDK.
3
+ */
4
+ export type Environment = "production" | "staging" | "preview" | "development";
5
+ export interface RouteflowInitOptions {
6
+ /** Optional ingest key for sending external spans to backend */
7
+ ingestKey?: string;
8
+ /** Enable/disable SDK (default: true) */
9
+ enabled?: boolean;
10
+ /** Environment name (default: "production") */
11
+ environment?: Environment;
12
+ /** Sample rate for events 0.0-1.0 (default: 1.0) */
13
+ sampleRate?: number;
14
+ /** Enable tracking of external dependency calls (default: false) */
15
+ trackExternal?: boolean;
16
+ /** Allowlist of hostnames for external tracking (default: []) */
17
+ externalAllowlist?: string[];
18
+ /** Flush interval in milliseconds (default: 1000) */
19
+ flushIntervalMs?: number;
20
+ /** Maximum queue size before dropping events (default: 5000) */
21
+ maxQueueSize?: number;
22
+ }
23
+ export interface ExternalSpanEvent {
24
+ type: "frontend_external_request_observed";
25
+ timestamp: string;
26
+ trace_id: string;
27
+ session_id: string;
28
+ environment: Environment;
29
+ frontend_route: string;
30
+ target_host: string;
31
+ method: string;
32
+ duration_ms: number;
33
+ outcome: "success" | "failure" | "unknown";
34
+ status_code?: number;
35
+ sdk: {
36
+ name: string;
37
+ version: string;
38
+ };
39
+ code_version?: string;
40
+ }
41
+ export interface IngestPayload {
42
+ sent_at: string;
43
+ events: ExternalSpanEvent[];
44
+ }
45
+ export interface RouteflowStats {
46
+ enabled: boolean;
47
+ session_id: string;
48
+ current_route: string;
49
+ events_queued: number;
50
+ events_sent: number;
51
+ events_dropped: number;
52
+ events_failed: number;
53
+ external_tracking_enabled: boolean;
54
+ has_ingest_key: boolean;
55
+ }
56
+ declare global {
57
+ interface Window {
58
+ __ROUTEFLOW_INGEST_KEY__?: string;
59
+ __ROUTEFLOW_CODE_VERSION__?: string;
60
+ }
61
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Type definitions for Routeflow Browser SDK.
3
+ */
4
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Validation utilities for Routeflow Browser SDK.
3
+ */
4
+ /**
5
+ * Validate that a target_host is just a hostname (no scheme, path, query).
6
+ */
7
+ export declare function validateTargetHost(host: string): boolean;
8
+ /**
9
+ * Check if a hostname matches an allowlist entry.
10
+ * Supports exact match and simple wildcard (*.example.com).
11
+ */
12
+ export declare function isHostAllowed(host: string, allowlist: string[]): boolean;
13
+ /**
14
+ * Normalize frontend route (remove query strings and fragments).
15
+ */
16
+ export declare function normalizeFrontendRoute(path: string): string;
17
+ /**
18
+ * Extract hostname from a URL string.
19
+ */
20
+ export declare function extractHostname(url: string): string | null;
21
+ /**
22
+ * Check if a URL is same-origin.
23
+ */
24
+ export declare function isSameOrigin(url: string): boolean;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Validation utilities for Routeflow Browser SDK.
3
+ */
4
+ /**
5
+ * Validate that a target_host is just a hostname (no scheme, path, query).
6
+ */
7
+ export function validateTargetHost(host) {
8
+ if (!host || typeof host !== "string") {
9
+ return false;
10
+ }
11
+ // Reject URLs with scheme
12
+ if (host.includes("://")) {
13
+ return false;
14
+ }
15
+ // Reject paths
16
+ if (host.includes("/")) {
17
+ return false;
18
+ }
19
+ // Reject query strings
20
+ if (host.includes("?")) {
21
+ return false;
22
+ }
23
+ // Reject fragments
24
+ if (host.includes("#")) {
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+ /**
30
+ * Check if a hostname matches an allowlist entry.
31
+ * Supports exact match and simple wildcard (*.example.com).
32
+ */
33
+ export function isHostAllowed(host, allowlist) {
34
+ if (!host || allowlist.length === 0) {
35
+ return false;
36
+ }
37
+ for (const pattern of allowlist) {
38
+ // Exact match
39
+ if (pattern === host) {
40
+ return true;
41
+ }
42
+ // Wildcard match (*.example.com)
43
+ if (pattern.startsWith("*.")) {
44
+ const suffix = pattern.slice(1); // Remove the *
45
+ if (host.endsWith(suffix) || host === suffix.slice(1)) {
46
+ return true;
47
+ }
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+ /**
53
+ * Normalize frontend route (remove query strings and fragments).
54
+ */
55
+ export function normalizeFrontendRoute(path) {
56
+ if (!path) {
57
+ return "/";
58
+ }
59
+ // Remove query string
60
+ const withoutQuery = path.split("?")[0];
61
+ // Remove fragment
62
+ const withoutFragment = withoutQuery.split("#")[0];
63
+ return withoutFragment || "/";
64
+ }
65
+ /**
66
+ * Extract hostname from a URL string.
67
+ */
68
+ export function extractHostname(url) {
69
+ try {
70
+ const urlObj = new URL(url);
71
+ return urlObj.hostname;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Check if a URL is same-origin.
79
+ */
80
+ export function isSameOrigin(url) {
81
+ try {
82
+ const urlObj = new URL(url, window.location.href);
83
+ return urlObj.origin === window.location.origin;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "routeflow-browser",
3
+ "version": "0.1.0",
4
+ "description": "Browser SDK for Routeflow - Frontend to backend correlation and external dependency tracking",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "prepublish": "npm run build"
10
+ },
11
+ "keywords": [
12
+ "routeflow",
13
+ "tracing",
14
+ "observability",
15
+ "frontend",
16
+ "correlation"
17
+ ],
18
+ "author": "Routeflow",
19
+ "license": "MIT",
20
+ "devDependencies": {
21
+ "typescript": "^5.0.0"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ]
27
+ }