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 +223 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +32 -0
- package/dist/external_spans.d.ts +39 -0
- package/dist/external_spans.js +233 -0
- package/dist/fetch_patch.d.ts +11 -0
- package/dist/fetch_patch.js +109 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/init.d.ts +16 -0
- package/dist/init.js +152 -0
- package/dist/route.d.ts +11 -0
- package/dist/route.js +42 -0
- package/dist/trace.d.ts +17 -0
- package/dist/trace.js +58 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +4 -0
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +88 -0
- package/package.json +27 -0
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
|
package/dist/client.d.ts
ADDED
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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
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
|
+
}
|
package/dist/route.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/trace.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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,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;
|
package/dist/validate.js
ADDED
|
@@ -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
|
+
}
|