Add frontend error collector, CORS/ingest guards, and static doc routes (Phase 3)
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s

- Add static/otel-rum.js: a framework-free browser script that captures
  uncaught errors, unhandled promise rejections and failed resource loads,
  translating them into OTLP log records POSTed to the existing /v1/logs
  endpoint (source: frontend attribute) so they flow through the same
  history/search/issue pipeline as backend telemetry.
- Add correlation-id support: traceHeaders() (manual) and same-origin
  fetch auto-instrumentation inject a W3C traceparent header, so a
  frontend error and the backend spans it triggered share a trace id and
  can be found together via GET /api/traces/{traceId}.
- Add CORS handling (withCORS in cmd/server/main.go) on the OTLP HTTP
  endpoints, required for any browser-origin POST.
- Add an optional shared ingest key (OTEL_INGEST_KEY, via '?key=' or
  X-Otel-Ingest-Key) and a per-IP rate limit (20 rps/burst 50) on OTLP
  HTTP ingestion, to scope/protect a public endpoint fed by a browser
  script. Verified end-to-end (CORS preflight, cross-origin POST, ingest
  key enforcement, rate limiting) against a running server.
- Serve static/otel-rum.js and agents_example.md as static HTTP routes so
  both the script and the agent instructions doc are fetchable directly
  from the base URL.
- Ignore the separately-cloned otel-ui/ frontend repo (has its own .git;
  not meant to be nested into this repo's history).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
developerandClaude Sonnet 5 committed 2026-09-17 10:20:42 +00:00
1 parent 0d274ff89c
commit c20a0b77f3
10 files changed
+489 -17

No files matched your search

+4 -1
View File
@@ -1,4 +1,7 @@
./server
.DS_Store
otel-agent.log
./semgrep-results.json
./semgrep-results.json
# Separate frontend repo, checked out locally for the Dockerfile's UI build
# stage; not nested into this repo's history.
/otel-ui/
+2
View File
@@ -37,6 +37,8 @@ WORKDIR /home/appuser/
COPY --from=go-builder /go-otel .
COPY --from=go-builder /app/otel-ui/dist ./dist
COPY static/ ./static
COPY agents_example.md .
# Ensure the appuser owns the files
RUN chown -R appuser:appgroup /home/appuser/
+48
View File
@@ -120,6 +120,14 @@ Errors seen in traces (spans with an error status) and logs (severity
- `GET /` - Serves the HTML visualization interface
### Static Routes
- `GET /static/otel-rum.js` - The frontend error collector script (see
below).
- `GET /agents_example.md` - Serves this repo's agent instructions doc
directly, so an AI agent can fetch it from the base URL before making any
API calls.
## Usage
### Sending Telemetry Data
@@ -156,8 +164,48 @@ curl http://localhost:8080/api/history/traces
This returns JSON arrays of historical entries with timestamps.
### Frontend Error Collector (`static/otel-rum.js`)
A framework-free browser script that captures uncaught errors, unhandled
promise rejections and failed resource loads (img/script/link), and ships
them as OTLP log records to the existing `/v1/logs` endpoint - so they flow
through the same history/search/issue pipeline as backend telemetry, tagged
`source: frontend`.
```html
<script src="https://otel.k6n.net/static/otel-rum.js"></script>
<script>
var rum = OtelRUM.init({ serviceName: "my-web-app" });
// Inject a W3C traceparent header into any HTTP client so a backend span
// (if the service is OTel-instrumented) shares this request's trace id -
// then GET /api/traces/{traceId} shows the frontend error and backend
// spans for the same request together.
fetch("/api/orders", { headers: rum.traceHeaders() });
// Manual error reporting.
try { risky(); } catch (err) { rum.captureException(err); }
</script>
```
`fetch` is auto-instrumented by default (same-origin requests only) to
inject `traceparent` and record request/response breadcrumbs; this can be
disabled via `injectTraceHeaders: false` / `autoInstrumentFetch: false`.
Out of scope for v1: source map resolution (stack traces stay minified),
session replay, PII scrubbing - don't route sensitive data through
breadcrumbs or attributes.
## Configuration
- `OTELLOG_PATH` - path to the persistent telemetry log file (default
`otel-agent.log`).
- `OTEL_INGEST_KEY` - optional shared key required (via `?key=` or the
`X-Otel-Ingest-Key` header) on `/v1/traces`, `/v1/metrics` and `/v1/logs`
over HTTP. Unset by default (no key required). This is meant to
scope/rate-limit a public ingest endpoint fed by a browser script, not to
provide real authentication - pair it with the built-in per-IP rate
limiting on those endpoints if the endpoint is public.
Currently, the history buffer size is hardcoded to 100 entries. This can be made configurable in future versions.
## Development
+1 -1
View File
@@ -1,6 +1,6 @@
# AI Agent Debugging with OTEL Collector
This document provides instructions for AI agents on how to utilize the OTEL Collector's debugging features to search and fetch telemetry data (traces, logs, and metrics).
This document provides instructions for AI agents on how to utilize the OTEL Collector's debugging features to search and fetch telemetry data (traces, logs, and metrics). It is also served as-is at `{Base URL}/agents_example.md`, so an agent can fetch it directly before making any API calls.
## Base Configuration
- **Base URL**: `https://otel.k6n.net`
+33 -7
View File
@@ -149,6 +149,23 @@ func (s *logsService) Export(ctx context.Context, req *logsgrpc.ExportLogsServic
return &logsgrpc.ExportLogsServiceResponse{}, nil
}
// withCORS allows cross-origin ingestion (e.g. from the otel-rum.js browser
// script running on a third-party site) into the OTLP HTTP endpoints. It's
// deliberately permissive on origin, since this is a public ingest endpoint
// scoped by the ingest key/rate limiting instead of same-origin policy.
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Otel-Ingest-Key")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
func main() {
// Configure history size (default 100, can be made configurable)
@@ -162,6 +179,7 @@ func main() {
wsServer := websocket.New(coll)
// Update collector with wsServer
coll.SetBroadcaster(wsServer)
coll.SetIngestKey(os.Getenv("OTEL_INGEST_KEY"))
// Start gRPC server
go func() {
@@ -186,17 +204,19 @@ func main() {
// OTLP HTTP mux (standard OTel port 4318)
otelMux := http.NewServeMux()
otelMux.HandleFunc("/v1/traces", coll.HandleTraces)
otelMux.HandleFunc("/v1/metrics", coll.HandleMetrics)
otelMux.HandleFunc("/v1/logs", coll.HandleLogs)
otelMux.HandleFunc("/v1/traces", withCORS(coll.HandleTraces))
otelMux.HandleFunc("/v1/metrics", withCORS(coll.HandleMetrics))
otelMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs))
otelMux.HandleFunc("/health", coll.HandleHealth)
// UI and API mux (port 8080)
uiMux := http.NewServeMux()
// Also register OTLP endpoints on 8080 for backwards compatibility
uiMux.HandleFunc("/v1/traces", coll.HandleTraces)
uiMux.HandleFunc("/v1/metrics", coll.HandleMetrics)
uiMux.HandleFunc("/v1/logs", coll.HandleLogs)
// Also register OTLP endpoints on 8080 for backwards compatibility.
// CORS is enabled here since this is the publicly exposed port (via the
// ingress) that a browser script such as static/otel-rum.js posts to.
uiMux.HandleFunc("/v1/traces", withCORS(coll.HandleTraces))
uiMux.HandleFunc("/v1/metrics", withCORS(coll.HandleMetrics))
uiMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs))
uiMux.HandleFunc("/health", coll.HandleHealth)
uiMux.HandleFunc("/api/history", coll.HandleHistory)
@@ -209,6 +229,12 @@ func main() {
uiMux.HandleFunc("/api/issues", coll.HandleIssues)
uiMux.HandleFunc("GET /api/issues/{id}", coll.HandleIssueByID)
uiMux.HandleFunc("/ws", wsServer.HandleConnections)
uiMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// Serve the agent instructions doc as a static route so an AI agent can
// fetch it directly from the base URL before making any API calls.
uiMux.HandleFunc("GET /agents_example.md", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "agents_example.md")
})
uiMux.Handle("/", spaHandler(http.Dir("./dist")))
log.Printf("Starting HTTP server on port 8080 (UI/API)")
+18 -8
View File
@@ -34,14 +34,24 @@ Sentry-like frontend error collector.
## Phase 3 — Frontend error collector (Sentry-style)
- [ ] `static/otel-rum.js`: captures `window.onerror`,
- [x] `static/otel-rum.js`: captures uncaught errors,
`unhandledrejection`, resource load failures; translates them to OTLP
log records client-side and POSTs to the existing `/v1/logs` endpoint
(so Phase 2's issue derivation picks them up for free via a
`source: frontend` attribute).
- [ ] CORS support on the OTLP HTTP endpoints (currently absent — required
for any browser-origin POST).
- [ ] Lightweight ingest key for scoping/rate-limiting noisy pages (not
real auth).
- [ ] Explicitly out of scope for v1: source map resolution, session
replay, PII scrubbing.
`source: frontend` attribute). Verified end-to-end against a running
server: an ingested frontend error shows up in `GET /api/issues` and
`GET /api/traces/{traceId}`.
- [x] Correlation-id injection: `traceHeaders()` (manual) and
auto-injection into same-origin `fetch` calls, using a W3C
`traceparent` header so a backend span sharing that trace id and the
frontend error that triggered it show up together via
`/api/traces/{traceId}`.
- [x] CORS support on the OTLP HTTP endpoints (`withCORS` in
`cmd/server/main.go`), verified with a live cross-origin POST.
- [x] Lightweight ingest key (`OTEL_INGEST_KEY`, checked via
`Collector.checkIngest`) for scoping noisy pages, plus a per-IP rate
limit (20 rps/burst 50) on the OTLP HTTP ingestion endpoints so a
runaway error loop can't hammer the collector. Not real auth.
- [x] Explicitly out of scope for v1: source map resolution, session
replay, PII scrubbing (documented in README.md and the script's
header comment).
+16
View File
@@ -32,6 +32,9 @@ type Collector struct {
subMutex sync.Mutex
waitLimiter *ipRateLimiter
issues *issues.Tracker
ingestLimiter *ipRateLimiter
ingestKey string
}
// New creates a new Collector instance.
@@ -45,9 +48,22 @@ func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store
// Allow bursts of a few immediate waits, then steady-state ~1 req/sec per IP.
waitLimiter: newIPRateLimiter(1, 5),
issues: issues.NewTracker(),
// Generous enough for normal exporter batching, but enough to stop a
// runaway client (e.g. a frontend error handler that itself throws,
// looping) from hammering the collector.
ingestLimiter: newIPRateLimiter(20, 50),
}
}
// SetIngestKey configures an optional shared key that HTTP OTLP ingestion
// (traces/metrics/logs) must present, via a '?key=' query parameter or an
// 'X-Otel-Ingest-Key' header. An empty key (the default) disables the check.
// This is meant to scope/rate-limit a public ingest endpoint (e.g. one fed
// by a browser script), not to provide real authentication.
func (c *Collector) SetIngestKey(key string) {
c.ingestKey = key
}
// ListIssues returns deduplicated error issues, most recently seen first.
func (c *Collector) ListIssues(limit int) []issues.Issue {
return c.issues.List(limit)
+59
View File
@@ -1,6 +1,7 @@
package collector
import (
"bytes"
"context"
"encoding/json"
"net/http"
@@ -318,3 +319,61 @@ func TestHandleIssuesAndByID(t *testing.T) {
t.Fatalf("expected 404 for unknown issue id, got %d", rec.Code)
}
}
func TestHandleLogsIngestKey(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
coll.SetIngestKey("secret")
body := []byte(`{"resourceLogs":[]}`)
cases := []struct {
name string
url string
header string
wantStatus int
}{
{"no key", "/v1/logs", "", http.StatusUnauthorized},
{"wrong key", "/v1/logs?key=wrong", "", http.StatusUnauthorized},
{"correct key in query", "/v1/logs?key=secret", "", http.StatusOK},
{"correct key in header", "/v1/logs", "secret", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, tc.url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if tc.header != "" {
req.Header.Set("X-Otel-Ingest-Key", tc.header)
}
// Shared IP across subtests; the ingest limiter's burst of 50 is
// well above this test's request count.
req.RemoteAddr = "198.51.100.1:1234"
rec := httptest.NewRecorder()
coll.HandleLogs(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("expected status %d, got %d (body=%s)", tc.wantStatus, rec.Code, rec.Body.String())
}
})
}
}
func TestHandleLogsIngestRateLimited(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
body := []byte(`{"resourceLogs":[]}`)
var lastCode int
for i := 0; i < 80; i++ {
req := httptest.NewRequest(http.MethodPost, "/v1/logs", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "198.51.100.2:1234"
rec := httptest.NewRecorder()
coll.HandleLogs(rec, req)
lastCode = rec.Code
if lastCode == http.StatusTooManyRequests {
break
}
}
if lastCode != http.StatusTooManyRequests {
t.Fatalf("expected ingest rate limiting to eventually return 429, last code was %d", lastCode)
}
}
+31
View File
@@ -19,6 +19,28 @@ import (
"go.opentelemetry.io/collector/pdata/ptrace"
)
// checkIngest applies the shared ingest rate limit and, if configured, the
// ingest key check. It writes an error response and returns false when the
// request should be rejected.
func (c *Collector) checkIngest(w http.ResponseWriter, r *http.Request) bool {
if !c.ingestLimiter.Allow(clientIP(r)) {
http.Error(w, "too many requests, slow down", http.StatusTooManyRequests)
return false
}
if c.ingestKey == "" {
return true
}
key := r.Header.Get("X-Otel-Ingest-Key")
if key == "" {
key = r.URL.Query().Get("key")
}
if key != c.ingestKey {
http.Error(w, "invalid or missing ingest key", http.StatusUnauthorized)
return false
}
return true
}
// HandleTraces handles OTLP trace requests.
func (c *Collector) HandleTraces(w http.ResponseWriter, r *http.Request) {
log.Printf("Received traces request: method=%s, content-type=%s", r.Method, r.Header.Get("Content-Type"))
@@ -26,6 +48,9 @@ func (c *Collector) HandleTraces(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !c.checkIngest(w, r) {
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -71,6 +96,9 @@ func (c *Collector) HandleMetrics(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !c.checkIngest(w, r) {
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -112,6 +140,9 @@ func (c *Collector) HandleLogs(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !c.checkIngest(w, r) {
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
+277
View File
@@ -0,0 +1,277 @@
/*!
* otel-rum.js - minimal browser error/RUM collector for go-otel.
*
* Captures uncaught errors, unhandled promise rejections and failed
* resource loads, and ships them as OTLP log records to this server's
* existing /v1/logs endpoint (no separate ingestion path needed - the
* backend's issue view picks these up like any other error log).
*
* Also exposes a `traceHeaders()` helper so outgoing `fetch`/XHR calls can
* carry a W3C `traceparent` header. If the backend service is itself
* OTel-instrumented and honors incoming trace context (most server
* instrumentation does by default), spans it creates for that request will
* share the same trace id - so a frontend error and the backend spans it
* triggered can be found together via GET /api/traces/{traceId}.
*
* Usage:
* <script src="https://otel.k6n.net/static/otel-rum.js"></script>
* <script>
* var rum = OtelRUM.init({ serviceName: 'my-web-app' });
*
* // Manual correlation-id injection into any HTTP client:
* fetch('/api/orders', { headers: rum.traceHeaders() });
*
* // Manual error reporting:
* try { risky(); } catch (err) { rum.captureException(err); }
* </script>
*
* Explicitly out of scope for v1: source map resolution (stack traces stay
* minified), session replay, PII scrubbing. Don't route sensitive data
* through breadcrumbs/attributes.
*/
(function (global) {
'use strict';
function randomHex(len) {
var chars = '0123456789abcdef';
var out = '';
for (var i = 0; i < len; i++) {
out += chars[(Math.random() * 16) | 0];
}
return out;
}
// W3C Trace Context ids: 16-byte trace id, 8-byte span id, both hex.
function newTraceId() {
return randomHex(32);
}
function newSpanId() {
return randomHex(16);
}
function isSameOrigin(url) {
try {
var resolved = new global.URL(url, global.location.href);
return resolved.origin === global.location.origin;
} catch (e) {
return false;
}
}
function toOtlpAttributes(obj) {
var out = [];
for (var key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
var value = obj[key];
if (value === undefined || value === null || value === '') continue;
out.push({ key: key, value: { stringValue: String(value) } });
}
return out;
}
function init(config) {
config = config || {};
var endpoint = config.endpoint || '/v1/logs';
var serviceName = config.serviceName || 'frontend';
var environment = config.environment || '';
var release = config.release || '';
var ingestKey = config.ingestKey || '';
var sampleRate = typeof config.sampleRate === 'number' ? config.sampleRate : 1;
var maxBreadcrumbs = config.maxBreadcrumbs || 20;
var captureConsoleErrors = config.captureConsoleErrors !== false;
var autoInstrumentFetch = config.autoInstrumentFetch !== false;
var injectTraceHeaders = config.injectTraceHeaders !== false;
var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only
var breadcrumbs = [];
// Best-effort correlation: the most recently generated trace id is
// attached to the next error report, so an error surfacing shortly
// after a failed request can usually be found alongside it. This is
// not a real per-async-context propagation mechanism.
var lastTraceId = '';
function addBreadcrumb(category, message) {
breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) });
if (breadcrumbs.length > maxBreadcrumbs) {
breadcrumbs.shift();
}
}
// traceHeaders returns a headers object carrying a fresh (or
// continued) W3C traceparent, ready to spread into any HTTP client's
// request headers - fetch, XHR, axios, etc.
function traceHeaders(existingTraceId) {
var traceId = existingTraceId || newTraceId();
var spanId = newSpanId();
lastTraceId = traceId;
return { traceparent: '00-' + traceId + '-' + spanId + '-01' };
}
function send(severity, message, attrs, traceId) {
if (sampleRate < 1 && Math.random() > sampleRate) return;
var record = {
timeUnixNano: String(Date.now() * 1e6),
severityText: severity,
severityNumber: severity === 'FATAL' ? 21 : 17,
body: { stringValue: String(message).slice(0, 2000) },
attributes: toOtlpAttributes(
Object.assign(
{
source: 'frontend',
'browser.url': global.location ? global.location.href : '',
'browser.userAgent': global.navigator ? global.navigator.userAgent : '',
'service.release': release,
'service.environment': environment,
breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined
},
attrs || {}
)
)
};
if (traceId) {
record.traceId = traceId;
}
var payload = {
resourceLogs: [
{
resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) },
scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: [record] }]
}
]
};
var body = JSON.stringify(payload);
var url = endpoint;
if (ingestKey) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
}
try {
if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
var blob = new global.Blob([body], { type: 'application/json' });
if (global.navigator.sendBeacon(url, blob)) return;
}
} catch (e) {
// fall through to fetch
}
if (typeof global.fetch === 'function') {
global.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true }).catch(function () {});
}
}
function captureException(err, extra) {
var message = (err && err.message) || String(err);
send(
'ERROR',
message,
Object.assign(
{
'exception.type': (err && err.name) || 'Error',
'exception.message': message,
'exception.stacktrace': (err && err.stack) || ''
},
extra || {}
),
lastTraceId
);
}
function captureMessage(message, severity, extra) {
send(severity || 'ERROR', message, extra, lastTraceId);
}
function onError(event) {
// Resource load errors (img/script/link) surface as an ErrorEvent-less
// Event targeting the failed element; script errors carry a message.
var target = event.target;
if (target && target !== global && target.tagName) {
var tag = String(target.tagName).toLowerCase();
if (tag === 'img' || tag === 'script' || tag === 'link') {
var url = target.src || target.href || '';
captureMessage('Failed to load ' + tag + ': ' + url, 'ERROR', {
'exception.type': 'ResourceLoadError',
'resource.tag': tag,
'resource.url': url
});
}
return;
}
var message = event.message || 'Script error';
captureException(event.error || { name: 'Error', message: message }, {
'source.file': event.filename || '',
'source.line': event.lineno,
'source.column': event.colno
});
}
function onUnhandledRejection(event) {
var reason = event.reason;
captureException(reason instanceof Error ? reason : { name: 'UnhandledRejection', message: String(reason) }, {
'exception.type': (reason && reason.name) || 'UnhandledRejection'
});
}
global.addEventListener('error', onError);
// Resource load errors don't bubble - must listen on the capturing phase.
global.addEventListener('error', onError, true);
global.addEventListener('unhandledrejection', onUnhandledRejection);
if (captureConsoleErrors && global.console && typeof global.console.error === 'function') {
var originalConsoleError = global.console.error;
global.console.error = function () {
try {
addBreadcrumb('console.error', Array.prototype.slice.call(arguments).join(' '));
} catch (e) {
/* ignore */
}
return originalConsoleError.apply(global.console, arguments);
};
}
if (autoInstrumentFetch && typeof global.fetch === 'function') {
var originalFetch = global.fetch;
global.fetch = function (input, requestInit) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
var method = (requestInit && requestInit.method) || (input && input.method) || 'GET';
if (injectTraceHeaders) {
var allowed = traceHeaderOrigins ? traceHeaderOrigins.some(function (o) { return url.indexOf(o) === 0; }) : isSameOrigin(url);
if (allowed) {
requestInit = requestInit || {};
var headers = new global.Headers(requestInit.headers || (input && input.headers) || {});
var th = traceHeaders();
headers.set('traceparent', th.traceparent);
requestInit = Object.assign({}, requestInit, { headers: headers });
}
}
return originalFetch.call(global, input, requestInit).then(
function (res) {
addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status);
return res;
},
function (err) {
addBreadcrumb('fetch', method + ' ' + url + ' -> error');
throw err;
}
);
};
}
return {
captureException: captureException,
captureMessage: captureMessage,
addBreadcrumb: addBreadcrumb,
newTraceId: newTraceId,
newSpanId: newSpanId,
traceHeaders: traceHeaders
};
}
global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId };
})(typeof window !== 'undefined' ? window : this);