You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@flagon.apache.org by au...@apache.org on 2026/10/11 15:27:21 UTC
(flagon) branch master updated: feat(userale): add OpenTelemetry OTLP/JSON exporter, OTel SDK bridge, and extension support (#140) (#185)
This is an automated email from the ASF dual-hosted git repository.
brucearctor pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flagon.git
The following commit(s) were added to refs/heads/master by this push:
new 7e5a5ec feat(userale): add OpenTelemetry OTLP/JSON exporter, OTel SDK bridge, and extension support (#140) (#185)
7e5a5ec is described below
commit 7e5a5ecc180f5f9c40ac8e81c2960f1daaca9e1e
Author: brucearctor <50...@users.noreply.github.com>
AuthorDate: Sun Oct 11 08:27:15 2026 -0700
feat(userale): add OpenTelemetry OTLP/JSON exporter, OTel SDK bridge, and extension support (#140) (#185)
---
.../src/background/messages/config_change.ts | 12 +-
.../flagon-userale-ext/src/options/logging.tsx | 28 +-
.../flagon-userale-ext/src/utils/storage.ts | 4 +
.../packages/flagon-userale/src/configure.ts | 2 +
.../packages/flagon-userale/src/exporters/index.ts | 41 +-
.../flagon-userale/src/exporters/otel-sdk.ts | 122 +++++
.../flagon-userale/src/exporters/otlp-json.ts | 563 +++++++++++++++++++++
.../flagon-userale/src/getInitialSettings.ts | 7 +-
.../userale/packages/flagon-userale/src/main.ts | 13 +
.../userale/packages/flagon-userale/src/types.d.ts | 9 +-
.../flagon-userale/test/spec/exporters.spec.ts | 245 ++++++++-
11 files changed, 1036 insertions(+), 10 deletions(-)
diff --git a/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts b/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts
index 7ad138e..667b557 100644
--- a/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts
+++ b/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts
@@ -21,16 +21,20 @@ import * as userale from "flagon-userale"
import type { PlasmoMessaging } from "@plasmohq/messaging"
-import type { StoredOptions } from "~utils/storage"
+import { getStoredOptions, type StoredOptions } from "~utils/storage"
let allowListRegExp: RegExp
-const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
- setOptions(req.body)
+const handler: PlasmoMessaging.MessageHandler = async (req) => {
+ const stored = await getStoredOptions()
+ setOptions({ ...stored, ...req.body })
}
export function setOptions(options: StoredOptions) {
- userale.options({ url: options.loggingUrl })
+ userale.options({
+ url: options.loggingUrl,
+ exporterType: options.exporterType ?? "http-json"
+ })
allowListRegExp = new RegExp(options.allowList)
switch (options.authMode) {
diff --git a/products/userale/packages/flagon-userale-ext/src/options/logging.tsx b/products/userale/packages/flagon-userale-ext/src/options/logging.tsx
index aa79e9a..f163a65 100644
--- a/products/userale/packages/flagon-userale-ext/src/options/logging.tsx
+++ b/products/userale/packages/flagon-userale-ext/src/options/logging.tsx
@@ -19,22 +19,33 @@
import { useEffect, useState } from "react"
-import { getStoredOptions, setStoredOptions } from "~/utils/storage"
+import {
+ getStoredOptions,
+ setStoredOptions,
+ type StoredOptions
+} from "~/utils/storage"
function Logging() {
const [loggingUrl, setLoggingUrl] = useState("")
const [allowList, setAllowList] = useState("")
+ const [exporterType, setExporterType] =
+ useState<StoredOptions["exporterType"]>("http-json")
useEffect(() => {
getStoredOptions().then((data) => {
setLoggingUrl(data.loggingUrl)
setAllowList(data.allowList)
+ setExporterType(data.exporterType)
})
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- const resp = await setStoredOptions({ loggingUrl, allowList })
+ const resp = await setStoredOptions({
+ loggingUrl,
+ allowList,
+ exporterType
+ })
alert(resp)
}
@@ -50,6 +61,19 @@ function Logging() {
/>
<br />
+ <label htmlFor="exporter-type">Exporter Format:</label>
+ <select
+ id="exporter-type"
+ value={exporterType}
+ onChange={(e) =>
+ setExporterType(e.target.value as StoredOptions["exporterType"])
+ }>
+ <option value="http-json">UserALE JSON (Default)</option>
+ <option value="otlp">OpenTelemetry (OTLP/JSON)</option>
+ <option value="proto-json">Protobuf v1 JSON</option>
+ </select>
+ <br />
+
<label htmlFor="allowlist">URL allowlist regex:</label>
<input
id="allowlist"
diff --git a/products/userale/packages/flagon-userale-ext/src/utils/storage.ts b/products/userale/packages/flagon-userale-ext/src/utils/storage.ts
index 8a61659..49e70b1 100644
--- a/products/userale/packages/flagon-userale-ext/src/utils/storage.ts
+++ b/products/userale/packages/flagon-userale-ext/src/utils/storage.ts
@@ -26,6 +26,7 @@ export const STORAGE_KEYS = {
apiKey: "apiKey",
authMode: "authMode",
allowList: "allowList",
+ exporterType: "exporterType",
loggingUrl: "loggingUrl"
} as const
@@ -36,6 +37,7 @@ export type StoredOptions = {
apiKey: string
authMode: "oauth" | "apikey" | "none"
allowList: string
+ exporterType: "http-json" | "otlp" | "proto-json"
loggingUrl: string
}
@@ -44,6 +46,7 @@ const DEFAULT_OPTIONS: StoredOptions = {
apiKey: "",
authMode: "none",
allowList: "https://flagon.apache.org/",
+ exporterType: "http-json",
loggingUrl: "http://localhost:8000"
}
@@ -57,6 +60,7 @@ export async function getStoredOptions(): Promise<StoredOptions> {
apiKey: stored.apiKey ?? DEFAULT_OPTIONS.apiKey,
authMode: stored.authMode ?? DEFAULT_OPTIONS.authMode,
allowList: stored.allowList ?? DEFAULT_OPTIONS.allowList,
+ exporterType: stored.exporterType ?? DEFAULT_OPTIONS.exporterType,
loggingUrl: stored.loggingUrl ?? DEFAULT_OPTIONS.loggingUrl
}
}
diff --git a/products/userale/packages/flagon-userale/src/configure.ts b/products/userale/packages/flagon-userale/src/configure.ts
index 0261521..afbf946 100644
--- a/products/userale/packages/flagon-userale/src/configure.ts
+++ b/products/userale/packages/flagon-userale/src/configure.ts
@@ -37,11 +37,13 @@ export class Configuration {
public logCountThreshold: number = 0;
public logDetails: boolean = false;
public on: boolean = false;
+ public otlpEndpoint: Settings.OtlpEndpoint = null;
public resolution: number = 0;
public sessionId: Settings.SessionId = null;
public time: Settings.TimeFunction = () => Date.now();
public toolName: Settings.ToolName = null;
public toolVersion: Settings.Version = null;
+ public traceContext: Settings.TraceContextCallback = null;
public transmitInterval: number = 0;
public url: string = "";
public userFromParams: Settings.UserFromParams = null;
diff --git a/products/userale/packages/flagon-userale/src/exporters/index.ts b/products/userale/packages/flagon-userale/src/exporters/index.ts
index c0721ac..c690a34 100644
--- a/products/userale/packages/flagon-userale/src/exporters/index.ts
+++ b/products/userale/packages/flagon-userale/src/exporters/index.ts
@@ -18,6 +18,23 @@
import type { Configuration } from "@/configure";
import { buildHeaders } from "./headers";
import { HttpJsonExporter, chunkLogsForKeepalive } from "./http-json";
+import { OtelSdkExporter } from "./otel-sdk";
+import type { OtelLogsApiLogger } from "./otel-sdk";
+import {
+ OtlpJsonExporter,
+ buildOtlpResourceAttributes,
+ resolveOtlpLogsEndpoint,
+ toOtlpAnyValue,
+ toOtlpExportLogsServiceRequest,
+ toOtlpLogRecord,
+ toUnixNanoString,
+} from "./otlp-json";
+import type {
+ OtlpAnyValue,
+ OtlpExportLogsServiceRequest,
+ OtlpKeyValue,
+ OtlpLogRecord,
+} from "./otlp-json";
import {
ProtoJsonExporter,
toProtoLogBatch,
@@ -33,14 +50,32 @@ import { WebSocketExporter } from "./websocket";
export {
buildHeaders,
+ buildOtlpResourceAttributes,
chunkLogsForKeepalive,
HttpJsonExporter,
+ OtelSdkExporter,
+ OtlpJsonExporter,
ProtoJsonExporter,
+ resolveOtlpLogsEndpoint,
+ toOtlpAnyValue,
+ toOtlpExportLogsServiceRequest,
+ toOtlpLogRecord,
toProtoLogBatch,
toProtoUserALELog,
+ toUnixNanoString,
WebSocketExporter,
};
-export type { ExportOptions, ExportResult, ExporterType, LogExporter };
+export type {
+ ExportOptions,
+ ExportResult,
+ ExporterType,
+ LogExporter,
+ OtelLogsApiLogger,
+ OtlpAnyValue,
+ OtlpExportLogsServiceRequest,
+ OtlpKeyValue,
+ OtlpLogRecord,
+};
/**
* Factory that creates the appropriate LogExporter for the given configuration.
@@ -50,6 +85,10 @@ export function createExporter(config: Configuration): LogExporter {
return config.exporter;
}
+ if (config.exporterType === "otlp" || config.otlpEndpoint) {
+ return new OtlpJsonExporter(config);
+ }
+
try {
const url = new URL(config.url);
if (
diff --git a/products/userale/packages/flagon-userale/src/exporters/otel-sdk.ts b/products/userale/packages/flagon-userale/src/exporters/otel-sdk.ts
new file mode 100644
index 0000000..7abd1f5
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/otel-sdk.ts
@@ -0,0 +1,122 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Logging } from "@/types";
+import type { ExportResult, LogExporter } from "./types";
+
+/**
+ * Minimal duck-typed interface matching @opentelemetry/api-logs Logger.
+ * Avoids taking a runtime dependency on @opentelemetry/api-logs.
+ */
+export interface OtelLogsApiLogger {
+ emit(logRecord: {
+ timestamp?: number;
+ observedTimestamp?: number;
+ severityNumber?: number;
+ severityText?: string;
+ eventName?: string;
+ body?: unknown;
+ attributes?: Record<string, unknown>;
+ }): void;
+}
+
+/**
+ * Bridges UserALE logs directly into an existing OpenTelemetry JS Logs SDK
+ * Logger instance (`@opentelemetry/api-logs`), allowing applications that
+ * already configure an OpenTelemetry LoggerProvider to emit UserALE events
+ * through their shared OTel pipeline and processors.
+ */
+export class OtelSdkExporter implements LogExporter {
+ private logger: OtelLogsApiLogger;
+
+ constructor(logger: OtelLogsApiLogger) {
+ this.logger = logger;
+ }
+
+ public async export(logs: Array<Logging.Log>): Promise<ExportResult> {
+ try {
+ for (const log of logs) {
+ const logType = typeof log.logType === "string" ? log.logType : "raw";
+ const eventType =
+ typeof log.type === "string" && log.type.trim().length > 0
+ ? log.type.trim()
+ : logType;
+ const eventName = `userale.${logType}.${eventType}`;
+
+ const timestamp =
+ typeof log.clientTime === "number"
+ ? log.clientTime
+ : typeof log.endTime === "number"
+ ? log.endTime
+ : Date.now();
+
+ const attributes: Record<string, unknown> = {
+ "event.name": eventName,
+ "flagon.log_type": logType,
+ "flagon.event_type": eventType,
+ };
+
+ if (typeof log.pageUrl === "string") {
+ attributes["url.full"] = log.pageUrl;
+ }
+ if (typeof log.pageTitle === "string") {
+ attributes["page.title"] = log.pageTitle;
+ }
+ if (typeof log.userId === "string") {
+ attributes["user.id"] = log.userId;
+ }
+ if (typeof log.sessionId === "string") {
+ attributes["session.id"] = log.sessionId;
+ }
+ if (typeof log.target === "string") {
+ attributes["flagon.target"] = log.target;
+ }
+ if (Array.isArray(log.path)) {
+ attributes["flagon.path"] = log.path;
+ }
+ if (typeof log.userAction === "boolean") {
+ attributes["flagon.user_action"] = log.userAction;
+ }
+
+ const loc = log.location as
+ { x?: number | null; y?: number | null } | undefined;
+ if (loc && typeof loc === "object") {
+ if (typeof loc.x === "number") {
+ attributes["flagon.location.x"] = loc.x;
+ }
+ if (typeof loc.y === "number") {
+ attributes["flagon.location.y"] = loc.y;
+ }
+ }
+
+ this.logger.emit({
+ timestamp,
+ observedTimestamp: timestamp,
+ severityNumber: 9,
+ severityText: "INFO",
+ eventName,
+ body: log.details ?? eventName,
+ attributes,
+ });
+ }
+ return { success: true };
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ return { success: false, error };
+ }
+ }
+}
diff --git a/products/userale/packages/flagon-userale/src/exporters/otlp-json.ts b/products/userale/packages/flagon-userale/src/exporters/otlp-json.ts
new file mode 100644
index 0000000..64e77f2
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/otlp-json.ts
@@ -0,0 +1,563 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Configuration } from "@/configure";
+import type { Logging } from "@/types";
+import { HttpJsonExporter } from "./http-json";
+
+export interface OtlpKeyValue {
+ key: string;
+ value: OtlpAnyValue;
+}
+
+export interface OtlpAnyValue {
+ stringValue?: string;
+ boolValue?: boolean;
+ intValue?: string;
+ doubleValue?: number;
+ arrayValue?: { values: Array<OtlpAnyValue> };
+ kvlistValue?: { values: Array<OtlpKeyValue> };
+}
+
+export interface OtlpLogRecord {
+ timeUnixNano: string;
+ observedTimeUnixNano: string;
+ severityNumber: number;
+ severityText: string;
+ eventName?: string;
+ body?: OtlpAnyValue;
+ attributes: Array<OtlpKeyValue>;
+ traceId?: string;
+ spanId?: string;
+}
+
+export interface OtlpExportLogsServiceRequest {
+ resourceLogs: Array<{
+ resource: {
+ attributes: Array<OtlpKeyValue>;
+ };
+ scopeLogs: Array<{
+ scope: {
+ name: string;
+ version: string;
+ };
+ logRecords: Array<OtlpLogRecord>;
+ }>;
+ }>;
+}
+
+const HEX_32_RE = /^[0-9a-f]{32}$/i;
+const HEX_16_RE = /^[0-9a-f]{16}$/i;
+const ALL_ZEROS_RE = /^0+$/;
+
+const KNOWN_LOG_KEYS = new Set([
+ "target",
+ "path",
+ "pageUrl",
+ "pageTitle",
+ "pageReferrer",
+ "browser",
+ "userAgent",
+ "clientTime",
+ "scrnRes",
+ "microTime",
+ "location",
+ "type",
+ "logType",
+ "userAction",
+ "details",
+ "userId",
+ "toolVersion",
+ "toolName",
+ "useraleVersion",
+ "sessionId",
+ "httpSessionId",
+ "browserSessionId",
+ "attributes",
+ "style",
+ "count",
+ "duration",
+ "startTime",
+ "endTime",
+ "targetChange",
+ "typeChange",
+ "traceId",
+ "spanId",
+]);
+
+/**
+ * Normalizes an OTLP HTTP endpoint URL. If the URL has an empty or root ("/")
+ * pathname, appends "/v1/logs" automatically. Custom paths are preserved.
+ */
+export function resolveOtlpLogsEndpoint(rawUrl: string): string {
+ try {
+ const parsed = new URL(
+ rawUrl,
+ typeof window !== "undefined" && window.location
+ ? window.location.origin
+ : "http://localhost:4318",
+ );
+ if (parsed.pathname === "" || parsed.pathname === "/") {
+ parsed.pathname = "/v1/logs";
+ return parsed.toString();
+ }
+ return rawUrl;
+ } catch {
+ const trimmed = rawUrl.replace(/\/+$/, "");
+ return `${trimmed}/v1/logs`;
+ }
+}
+
+/**
+ * Converts an arbitrary JavaScript value into an OTLP/JSON AnyValue object.
+ */
+export function toOtlpAnyValue(val: unknown): OtlpAnyValue {
+ if (val === null || val === undefined) {
+ return { stringValue: "" };
+ }
+ if (typeof val === "string") {
+ return { stringValue: val };
+ }
+ if (typeof val === "boolean") {
+ return { boolValue: val };
+ }
+ if (typeof val === "bigint") {
+ return { intValue: val.toString(10) };
+ }
+ if (typeof val === "number") {
+ if (!Number.isFinite(val)) {
+ return { stringValue: String(val) };
+ }
+ if (Number.isInteger(val)) {
+ return { intValue: BigInt(val).toString(10) };
+ }
+ return { doubleValue: val };
+ }
+ if (Array.isArray(val)) {
+ return {
+ arrayValue: {
+ values: val.map((item) => toOtlpAnyValue(item)),
+ },
+ };
+ }
+ if (typeof val === "object") {
+ const entries = Object.entries(val as Record<string, unknown>).filter(
+ ([, v]) => v !== undefined,
+ );
+ return {
+ kvlistValue: {
+ values: entries.map(([key, v]) => ({
+ key,
+ value: toOtlpAnyValue(v),
+ })),
+ },
+ };
+ }
+ return { stringValue: String(val) };
+}
+
+function pushStringAttr(
+ attrs: Array<OtlpKeyValue>,
+ key: string,
+ val: unknown,
+): void {
+ if (typeof val === "string" && val.length > 0) {
+ attrs.push({ key, value: { stringValue: val } });
+ }
+}
+
+function pushIntAttr(
+ attrs: Array<OtlpKeyValue>,
+ key: string,
+ val: unknown,
+): void {
+ if (typeof val === "number" && Number.isFinite(val)) {
+ attrs.push({
+ key,
+ value: { intValue: BigInt(Math.trunc(val)).toString(10) },
+ });
+ }
+}
+
+function pushDoubleAttr(
+ attrs: Array<OtlpKeyValue>,
+ key: string,
+ val: unknown,
+): void {
+ if (typeof val === "number" && Number.isFinite(val)) {
+ attrs.push({ key, value: { doubleValue: val } });
+ }
+}
+
+function pushBoolAttr(
+ attrs: Array<OtlpKeyValue>,
+ key: string,
+ val: unknown,
+): void {
+ if (typeof val === "boolean") {
+ attrs.push({ key, value: { boolValue: val } });
+ }
+}
+
+/**
+ * Computes a base-10 nanosecond UNIX timestamp string using BigInt arithmetic
+ * to avoid IEEE-754 double precision loss beyond Number.MAX_SAFE_INTEGER.
+ */
+export function toUnixNanoString(
+ clientTimeMs: unknown,
+ microTime?: unknown,
+): string {
+ const ms =
+ typeof clientTimeMs === "number" && Number.isFinite(clientTimeMs)
+ ? Math.trunc(clientTimeMs)
+ : Date.now();
+ let subMsNanos = 0;
+ if (typeof microTime === "number" && Number.isFinite(microTime)) {
+ const frac = microTime - Math.trunc(microTime);
+ if (frac > 0 && frac < 1) {
+ subMsNanos = Math.round(frac * 1_000_000);
+ }
+ }
+ return (BigInt(ms) * BigInt(1_000_000) + BigInt(subMsNanos)).toString(10);
+}
+
+/**
+ * Extracts active W3C TraceContext (traceId, spanId) from the log entry,
+ * configured traceContext callback, or global OpenTelemetry API if present.
+ */
+function resolveTraceContext(
+ log: Logging.Log,
+ config?: Configuration,
+): { traceId?: string; spanId?: string } {
+ let rawTraceId = typeof log.traceId === "string" ? log.traceId : undefined;
+ let rawSpanId = typeof log.spanId === "string" ? log.spanId : undefined;
+
+ if (
+ (!rawTraceId || !rawSpanId) &&
+ typeof config?.traceContext === "function"
+ ) {
+ try {
+ const ctx = config.traceContext();
+ if (ctx) {
+ rawTraceId = rawTraceId ?? ctx.traceId;
+ rawSpanId = rawSpanId ?? ctx.spanId;
+ }
+ } catch {
+ // Ignore user callback errors
+ }
+ }
+
+ if (!rawTraceId || !rawSpanId) {
+ const globalOtel = (
+ globalThis as unknown as {
+ opentelemetry?: {
+ trace?: {
+ getActiveSpan?: () =>
+ | {
+ spanContext?: () => { traceId?: string; spanId?: string };
+ }
+ | undefined;
+ };
+ };
+ }
+ ).opentelemetry;
+ const spanCtx = globalOtel?.trace?.getActiveSpan?.()?.spanContext?.();
+ if (spanCtx) {
+ rawTraceId = rawTraceId ?? spanCtx.traceId;
+ rawSpanId = rawSpanId ?? spanCtx.spanId;
+ }
+ }
+
+ const result: { traceId?: string; spanId?: string } = {};
+ if (
+ rawTraceId &&
+ HEX_32_RE.test(rawTraceId) &&
+ !ALL_ZEROS_RE.test(rawTraceId)
+ ) {
+ result.traceId = rawTraceId.toLowerCase();
+ }
+ if (rawSpanId && HEX_16_RE.test(rawSpanId) && !ALL_ZEROS_RE.test(rawSpanId)) {
+ result.spanId = rawSpanId.toLowerCase();
+ }
+ return result;
+}
+
+/**
+ * Builds the OTLP Resource attributes hoisted from the batch and configuration.
+ */
+export function buildOtlpResourceAttributes(
+ logs: Array<Logging.Log>,
+ config?: Configuration,
+): Array<OtlpKeyValue> {
+ const first = logs[0] ?? {};
+ const attrs: Array<OtlpKeyValue> = [];
+
+ const serviceName =
+ (typeof first.toolName === "string" && first.toolName) ||
+ config?.toolName ||
+ "unknown_service:userale";
+ attrs.push({ key: "service.name", value: { stringValue: serviceName } });
+
+ const serviceVersion =
+ (typeof first.toolVersion === "string" && first.toolVersion) ||
+ config?.toolVersion;
+ pushStringAttr(attrs, "service.version", serviceVersion);
+
+ attrs.push({
+ key: "telemetry.sdk.name",
+ value: { stringValue: "apache-flagon-userale" },
+ });
+ attrs.push({
+ key: "telemetry.sdk.language",
+ value: { stringValue: "webjs" },
+ });
+
+ const sdkVersion =
+ (typeof first.useraleVersion === "string" && first.useraleVersion) ||
+ config?.useraleVersion ||
+ "2.4.0";
+ pushStringAttr(attrs, "telemetry.sdk.version", sdkVersion);
+
+ pushStringAttr(attrs, "user_agent.original", first.userAgent);
+
+ const browserObj = first.browser as
+ { browser?: string; version?: string } | undefined;
+ if (browserObj && typeof browserObj === "object") {
+ pushStringAttr(attrs, "browser.name", browserObj.browser);
+ pushStringAttr(attrs, "browser.version", browserObj.version);
+ }
+
+ const sessionId =
+ (typeof first.sessionId === "string" && first.sessionId) ||
+ config?.sessionId;
+ pushStringAttr(attrs, "session.id", sessionId);
+
+ const httpSessionId =
+ (typeof first.httpSessionId === "string" && first.httpSessionId) ||
+ config?.httpSessionId;
+ pushStringAttr(attrs, "flagon.http_session_id", httpSessionId);
+
+ const browserSessionId =
+ (typeof first.browserSessionId === "string" && first.browserSessionId) ||
+ config?.browserSessionId;
+ pushStringAttr(attrs, "flagon.browser_session_id", browserSessionId);
+
+ const scrnRes = first.scrnRes as
+ { width?: number; height?: number } | undefined;
+ if (scrnRes && typeof scrnRes === "object") {
+ pushIntAttr(attrs, "screen.width", scrnRes.width);
+ pushIntAttr(attrs, "screen.height", scrnRes.height);
+ }
+
+ return attrs;
+}
+
+/**
+ * Converts a single UserALE log entry into an OTLP LogRecord.
+ */
+export function toOtlpLogRecord(
+ log: Logging.Log,
+ config?: Configuration,
+): OtlpLogRecord {
+ const logType = typeof log.logType === "string" ? log.logType : "raw";
+ const eventType =
+ typeof log.type === "string" && log.type.trim().length > 0
+ ? log.type.trim()
+ : logType;
+ const eventName = `userale.${logType}.${eventType}`;
+
+ const primaryTimeMs =
+ typeof log.clientTime === "number"
+ ? log.clientTime
+ : typeof log.endTime === "number"
+ ? log.endTime
+ : typeof log.startTime === "number"
+ ? log.startTime
+ : Date.now();
+ const timeUnixNano = toUnixNanoString(primaryTimeMs, log.microTime);
+
+ const attrs: Array<OtlpKeyValue> = [
+ { key: "event.name", value: { stringValue: eventName } },
+ { key: "flagon.log_type", value: { stringValue: logType } },
+ { key: "flagon.event_type", value: { stringValue: eventType } },
+ ];
+
+ pushStringAttr(attrs, "url.full", log.pageUrl);
+ pushStringAttr(attrs, "page.title", log.pageTitle);
+ pushStringAttr(attrs, "page.referrer", log.pageReferrer);
+ pushStringAttr(attrs, "user.id", log.userId);
+ pushStringAttr(attrs, "flagon.target", log.target);
+ pushBoolAttr(attrs, "flagon.user_action", log.userAction);
+
+ if (Array.isArray(log.path) && log.path.length > 0) {
+ attrs.push({
+ key: "flagon.path",
+ value: {
+ arrayValue: {
+ values: log.path.map((p) => ({ stringValue: String(p) })),
+ },
+ },
+ });
+ }
+
+ const loc = log.location as
+ { x?: number | null; y?: number | null } | undefined;
+ if (loc && typeof loc === "object") {
+ pushDoubleAttr(attrs, "flagon.location.x", loc.x);
+ pushDoubleAttr(attrs, "flagon.location.y", loc.y);
+ }
+
+ if (logType === "interval") {
+ pushIntAttr(attrs, "flagon.interval.count", log.count);
+ pushIntAttr(attrs, "flagon.interval.duration_ms", log.duration);
+ pushIntAttr(attrs, "flagon.interval.start_time_ms", log.startTime);
+ pushIntAttr(attrs, "flagon.interval.end_time_ms", log.endTime);
+ pushBoolAttr(attrs, "flagon.interval.target_change", log.targetChange);
+ pushBoolAttr(attrs, "flagon.interval.type_change", log.typeChange);
+ }
+
+ if (
+ log.attributes &&
+ typeof log.attributes === "object" &&
+ !Array.isArray(log.attributes) &&
+ Object.keys(log.attributes as Record<string, unknown>).length > 0
+ ) {
+ attrs.push({
+ key: "flagon.element.attributes",
+ value: toOtlpAnyValue(log.attributes),
+ });
+ }
+
+ if (
+ log.style &&
+ typeof log.style === "object" &&
+ !Array.isArray(log.style) &&
+ Object.keys(log.style as Record<string, unknown>).length > 0
+ ) {
+ attrs.push({
+ key: "flagon.element.style",
+ value: toOtlpAnyValue(log.style),
+ });
+ }
+
+ // Collect any top-level custom fields attached via packageCustomLog / userale.map
+ const extraFields: Record<string, unknown> = {};
+ for (const [key, val] of Object.entries(log)) {
+ if (!KNOWN_LOG_KEYS.has(key) && val !== undefined) {
+ extraFields[key] = val;
+ }
+ }
+
+ const bodyObj: Record<string, unknown> = {};
+ if (
+ log.details &&
+ typeof log.details === "object" &&
+ !Array.isArray(log.details)
+ ) {
+ Object.assign(bodyObj, log.details);
+ } else if (log.details !== undefined && log.details !== null) {
+ bodyObj.details = log.details;
+ }
+ Object.assign(bodyObj, extraFields);
+
+ const body: OtlpAnyValue =
+ Object.keys(bodyObj).length > 0
+ ? toOtlpAnyValue(bodyObj)
+ : {
+ stringValue:
+ typeof log.target === "string" && log.target
+ ? `${eventType} on ${log.target}`
+ : eventType,
+ };
+
+ const record: OtlpLogRecord = {
+ timeUnixNano,
+ observedTimeUnixNano: timeUnixNano,
+ severityNumber: 9,
+ severityText: "INFO",
+ eventName,
+ body,
+ attributes: attrs,
+ };
+
+ const traceCtx = resolveTraceContext(log, config);
+ if (traceCtx.traceId) {
+ record.traceId = traceCtx.traceId;
+ }
+ if (traceCtx.spanId) {
+ record.spanId = traceCtx.spanId;
+ }
+
+ return record;
+}
+
+/**
+ * Converts a batch of UserALE logs into an OTLP/JSON ExportLogsServiceRequest payload.
+ */
+export function toOtlpExportLogsServiceRequest(
+ logs: Array<Logging.Log>,
+ config?: Configuration,
+): OtlpExportLogsServiceRequest {
+ const first = logs[0] ?? {};
+ const scopeVersion =
+ (typeof first.useraleVersion === "string" && first.useraleVersion) ||
+ config?.useraleVersion ||
+ "2.4.0";
+
+ return {
+ resourceLogs: [
+ {
+ resource: {
+ attributes: buildOtlpResourceAttributes(logs, config),
+ },
+ scopeLogs: [
+ {
+ scope: {
+ name: "org.apache.flagon.userale",
+ version: scopeVersion,
+ },
+ logRecords: logs.map((log) => toOtlpLogRecord(log, config)),
+ },
+ ],
+ },
+ ],
+ };
+}
+
+/**
+ * Exports UserALE log batches in OpenTelemetry OTLP/JSON (ExportLogsServiceRequest)
+ * format over HTTP/HTTPS.
+ */
+export class OtlpJsonExporter extends HttpJsonExporter {
+ private otlpConfig: Configuration;
+
+ constructor(config: Configuration) {
+ super(config);
+ this.otlpConfig = config;
+ }
+
+ protected override getEndpoint(): string {
+ const raw = this.otlpConfig.otlpEndpoint || this.otlpConfig.url;
+ return resolveOtlpLogsEndpoint(raw);
+ }
+
+ protected override serialize(logs: Array<Logging.Log>): string {
+ return JSON.stringify(
+ toOtlpExportLogsServiceRequest(logs, this.otlpConfig),
+ );
+ }
+}
diff --git a/products/userale/packages/flagon-userale/src/getInitialSettings.ts b/products/userale/packages/flagon-userale/src/getInitialSettings.ts
index 14118db..9af090f 100644
--- a/products/userale/packages/flagon-userale/src/getInitialSettings.ts
+++ b/products/userale/packages/flagon-userale/src/getInitialSettings.ts
@@ -44,11 +44,13 @@ export function getInitialSettings(): Settings.Config {
httpSessionId: null,
logCountThreshold: +5,
logDetails: false,
+ otlpEndpoint: null,
resolution: +500,
sessionId: sessionId,
time: (ts?: number) => (ts !== undefined ? ts : Date.now()),
toolName: null,
toolVersion: null,
+ traceContext: null,
transmitInterval: +5000,
url: "http://localhost:8000",
useraleVersion: null,
@@ -85,9 +87,10 @@ export function getInitialSettings(): Settings.Config {
return null;
};
const headers = get("data-headers");
+ const otlpEndpoint = get("data-otlp-endpoint") || null;
const exporterAttr = (get("data-exporter-type") ||
get("data-exporter") ||
- "http-json") as Settings.ExporterType;
+ (otlpEndpoint ? "otlp" : "http-json")) as Settings.ExporterType;
const settings: Settings.Config = {
apiKey: get("data-api-key") || null,
authHeader: get("data-auth") || null,
@@ -100,11 +103,13 @@ export function getInitialSettings(): Settings.Config {
httpSessionId: httpSessionId,
logCountThreshold: +(get("data-threshold") || 5),
logDetails: get("data-log-details") === "true" ? true : false,
+ otlpEndpoint: otlpEndpoint,
resolution: +(get("data-resolution") || 500),
sessionId: get("data-session") || sessionId,
time: timeStampScale(document.createEvent("CustomEvent")),
toolName: get("data-tool") || null,
toolVersion: get("data-version") || null,
+ traceContext: null,
transmitInterval: +(get("data-interval") || 5000),
url: get("data-url") || "http://localhost:8000",
useraleVersion: get("data-userale-version") || null,
diff --git a/products/userale/packages/flagon-userale/src/main.ts b/products/userale/packages/flagon-userale/src/main.ts
index 4934776..eaedbcd 100644
--- a/products/userale/packages/flagon-userale/src/main.ts
+++ b/products/userale/packages/flagon-userale/src/main.ts
@@ -57,11 +57,19 @@ export {
} from "@/packageLogs";
export {
buildHeaders,
+ buildOtlpResourceAttributes,
createExporter,
HttpJsonExporter,
+ OtelSdkExporter,
+ OtlpJsonExporter,
ProtoJsonExporter,
+ resolveOtlpLogsEndpoint,
+ toOtlpAnyValue,
+ toOtlpExportLogsServiceRequest,
+ toOtlpLogRecord,
toProtoLogBatch,
toProtoUserALELog,
+ toUnixNanoString,
WebSocketExporter,
} from "@/exporters";
export type {
@@ -69,6 +77,11 @@ export type {
ExportResult,
ExporterType,
LogExporter,
+ OtelLogsApiLogger,
+ OtlpAnyValue,
+ OtlpExportLogsServiceRequest,
+ OtlpKeyValue,
+ OtlpLogRecord,
} from "@/exporters";
export type { Logging, Settings } from "@/types";
diff --git a/products/userale/packages/flagon-userale/src/types.d.ts b/products/userale/packages/flagon-userale/src/types.d.ts
index d7c0d35..f5952ee 100644
--- a/products/userale/packages/flagon-userale/src/types.d.ts
+++ b/products/userale/packages/flagon-userale/src/types.d.ts
@@ -32,6 +32,9 @@ export declare namespace Settings {
type Headers = HeaderObject | null;
type ExporterType = ExporterMode;
type CustomExporter = LogExporter | null;
+ type OtlpEndpoint = string | null;
+ type TraceContextCallback =
+ (() => { traceId?: string; spanId?: string } | null) | null;
type ConfigValueTypes =
| string
| number
@@ -48,7 +51,9 @@ export declare namespace Settings {
| CustomIndex
| Headers
| ExporterType
- | CustomExporter;
+ | CustomExporter
+ | OtlpEndpoint
+ | TraceContextCallback;
type TimeFunction = (() => number) | ((ts: number) => number);
@@ -69,11 +74,13 @@ export declare namespace Settings {
logCountThreshold: number;
logDetails: boolean;
on?: boolean;
+ otlpEndpoint?: OtlpEndpoint;
resolution: number;
sessionId: SessionId;
time: TimeFunction;
toolName: ToolName;
toolVersion?: Version;
+ traceContext?: TraceContextCallback;
transmitInterval: number;
url: string;
userFromParams: UserFromParams;
diff --git a/products/userale/packages/flagon-userale/test/spec/exporters.spec.ts b/products/userale/packages/flagon-userale/test/spec/exporters.spec.ts
index 008ccc3..8ff2c14 100644
--- a/products/userale/packages/flagon-userale/test/spec/exporters.spec.ts
+++ b/products/userale/packages/flagon-userale/test/spec/exporters.spec.ts
@@ -24,8 +24,13 @@ import {
chunkLogsForKeepalive,
createExporter,
HttpJsonExporter,
+ OtelSdkExporter,
+ OtlpJsonExporter,
ProtoJsonExporter,
+ resolveOtlpLogsEndpoint,
+ toOtlpExportLogsServiceRequest,
toProtoLogBatch,
+ toUnixNanoString,
WebSocketExporter,
} from "@/exporters";
import { LogBatchSchema, LogType } from "@/gen/apache/flagon/userale/v1/log_pb";
@@ -38,7 +43,7 @@ import {
} from "@/utils";
import { fromJson } from "@bufbuild/protobuf";
-import type { LogExporter } from "@/exporters";
+import type { LogExporter, OtlpKeyValue } from "@/exporters";
import type { Logging } from "@/types";
import type { JsonValue } from "@bufbuild/protobuf";
@@ -108,6 +113,20 @@ describe("exporters", () => {
expect(exporter).toBeInstanceOf(ProtoJsonExporter);
});
+ it("creates OtlpJsonExporter when exporterType is 'otlp' or otlpEndpoint is set", () => {
+ config.update({
+ url: "http://localhost:4318",
+ exporterType: "otlp",
+ });
+ expect(createExporter(config)).toBeInstanceOf(OtlpJsonExporter);
+
+ config.reset();
+ config.update({
+ otlpEndpoint: "http://localhost:4318",
+ });
+ expect(createExporter(config)).toBeInstanceOf(OtlpJsonExporter);
+ });
+
it("returns custom config.exporter when provided", () => {
const customExporter: LogExporter = {
export: jest.fn().mockResolvedValue({ success: true }),
@@ -346,4 +365,228 @@ describe("exporters", () => {
fetchSpy.mockRestore();
});
});
+
+ describe("OtlpJsonExporter & OTLP/JSON mapping (#140)", () => {
+ function getAttrMap(
+ attrs: Array<OtlpKeyValue>,
+ ): Record<string, OtlpKeyValue["value"]> {
+ return Object.fromEntries(attrs.map((a) => [a.key, a.value]));
+ }
+
+ it("normalizes OTLP endpoints by appending /v1/logs only when path is empty or root", () => {
+ expect(resolveOtlpLogsEndpoint("http://localhost:4318")).toBe(
+ "http://localhost:4318/v1/logs",
+ );
+ expect(resolveOtlpLogsEndpoint("http://localhost:4318/")).toBe(
+ "http://localhost:4318/v1/logs",
+ );
+ expect(
+ resolveOtlpLogsEndpoint("https://collector.example.com/otel/v1/logs"),
+ ).toBe("https://collector.example.com/otel/v1/logs");
+ });
+
+ it("computes nanosecond timestamps using BigInt without float precision loss", () => {
+ expect(toUnixNanoString(1712000000000, 123.456)).toBe(
+ "1712000000000456000",
+ );
+ expect(toUnixNanoString(1712000000000)).toBe("1712000000000000000");
+ });
+
+ it("hoists constant device/session fields to Resource and maps LogRecord with structured kvlistValue body", () => {
+ config.update({
+ toolName: "checkout-ui",
+ toolVersion: "3.1.0",
+ useraleVersion: "2.5.0",
+ });
+
+ const rawLog: Logging.Log = {
+ target: "button#pay",
+ path: ["button#pay", "form#checkout", "body"],
+ pageUrl: "https://shop.example.com/pay",
+ pageTitle: "Checkout",
+ pageReferrer: "https://shop.example.com/cart",
+ browser: { browser: "chrome", version: "125.0" },
+ userAgent: "Mozilla/5.0 Chrome/125.0",
+ clientTime: 1712000000000,
+ microTime: 10.25,
+ scrnRes: { width: 1440, height: 900 },
+ location: { x: 42, y: 84 },
+ type: "click",
+ logType: "raw",
+ userAction: true,
+ details: { button: 0, ctrl: false, nested: { ok: true } },
+ userId: "user-99",
+ sessionId: "sess-xyz",
+ httpSessionId: "http-xyz",
+ browserSessionId: "browser-xyz",
+ attributes: { "data-testid": "pay-btn" },
+ style: { display: "inline-block" },
+ traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
+ spanId: "00f067aa0ba902b7",
+ };
+
+ const payload = toOtlpExportLogsServiceRequest([rawLog], config);
+ expect(payload.resourceLogs).toHaveLength(1);
+
+ const resourceLog = payload.resourceLogs[0]!;
+ const resAttrs = getAttrMap(resourceLog.resource.attributes);
+ expect(resAttrs["service.name"]).toEqual({ stringValue: "checkout-ui" });
+ expect(resAttrs["service.version"]).toEqual({ stringValue: "3.1.0" });
+ expect(resAttrs["telemetry.sdk.name"]).toEqual({
+ stringValue: "apache-flagon-userale",
+ });
+ expect(resAttrs["telemetry.sdk.language"]).toEqual({
+ stringValue: "webjs",
+ });
+ expect(resAttrs["user_agent.original"]).toEqual({
+ stringValue: "Mozilla/5.0 Chrome/125.0",
+ });
+ expect(resAttrs["session.id"]).toEqual({ stringValue: "sess-xyz" });
+ expect(resAttrs["screen.width"]).toEqual({ intValue: "1440" });
+ expect(resAttrs["screen.height"]).toEqual({ intValue: "900" });
+
+ const scopeLog = resourceLog.scopeLogs[0]!;
+ expect(scopeLog.scope.name).toBe("org.apache.flagon.userale");
+ expect(scopeLog.logRecords).toHaveLength(1);
+
+ const record = scopeLog.logRecords[0]!;
+ expect(record.timeUnixNano).toBe("1712000000000250000");
+ expect(record.severityNumber).toBe(9);
+ expect(record.severityText).toBe("INFO");
+ expect(record.eventName).toBe("userale.raw.click");
+ expect(record.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
+ expect(record.spanId).toBe("00f067aa0ba902b7");
+
+ // Structured kvlistValue body instead of double-encoded JSON string
+ expect(record.body?.kvlistValue).toBeDefined();
+ const bodyMap = getAttrMap(record.body!.kvlistValue!.values);
+ expect(bodyMap.button).toEqual({ intValue: "0" });
+ expect(bodyMap.ctrl).toEqual({ boolValue: false });
+ expect(bodyMap.nested?.kvlistValue?.values).toEqual([
+ { key: "ok", value: { boolValue: true } },
+ ]);
+
+ const logAttrs = getAttrMap(record.attributes);
+ expect(logAttrs["event.name"]).toEqual({
+ stringValue: "userale.raw.click",
+ });
+ expect(logAttrs["url.full"]).toEqual({
+ stringValue: "https://shop.example.com/pay",
+ });
+ expect(logAttrs["user.id"]).toEqual({ stringValue: "user-99" });
+ expect(logAttrs["flagon.target"]).toEqual({ stringValue: "button#pay" });
+ expect(logAttrs["flagon.user_action"]).toEqual({ boolValue: true });
+ expect(logAttrs["flagon.location.x"]).toEqual({ doubleValue: 42 });
+ expect(logAttrs["flagon.location.y"]).toEqual({ doubleValue: 84 });
+ expect(logAttrs["flagon.path"]).toEqual({
+ arrayValue: {
+ values: [
+ { stringValue: "button#pay" },
+ { stringValue: "form#checkout" },
+ { stringValue: "body" },
+ ],
+ },
+ });
+ });
+
+ it("supports config.traceContext callback and ignores invalid/all-zero trace IDs", () => {
+ config.update({
+ traceContext: () => ({
+ traceId: "ABCD2f3577b34da6a3ce929d0e0e4736",
+ spanId: "0000000000000000", // all-zero spanId should be ignored
+ }),
+ });
+
+ const payload = toOtlpExportLogsServiceRequest(
+ [
+ {
+ target: "div",
+ type: "click",
+ logType: "raw",
+ clientTime: 1712000000000,
+ },
+ ],
+ config,
+ );
+ const record = payload.resourceLogs[0]!.scopeLogs[0]!.logRecords[0]!;
+ expect(record.traceId).toBe("abcd2f3577b34da6a3ce929d0e0e4736");
+ expect(record.spanId).toBeUndefined();
+ });
+
+ it("POSTs OTLP/JSON payload to normalized /v1/logs endpoint", async () => {
+ const fetchSpy = jest
+ .spyOn(global, "fetch")
+ .mockResolvedValue(new Response(null, { status: 200 }));
+
+ config.update({
+ otlpEndpoint: "http://localhost:4318",
+ exporterType: "otlp",
+ apiKey: "otlp-secret",
+ });
+
+ const exporter = new OtlpJsonExporter(config);
+ const result = await exporter.export([
+ {
+ target: "button#save",
+ type: "click",
+ logType: "raw",
+ clientTime: 1712000000000,
+ },
+ ]);
+
+ expect(result.success).toBe(true);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(fetchSpy.mock.calls[0]![0]).toBe("http://localhost:4318/v1/logs");
+ const headers = fetchSpy.mock.calls[0]![1]!.headers as Headers;
+ expect(headers.get("x-api-key")).toBe("otlp-secret");
+
+ const sentBody = JSON.parse(
+ fetchSpy.mock.calls[0]![1]!.body as string,
+ ) as ReturnType<typeof toOtlpExportLogsServiceRequest>;
+ expect(
+ sentBody.resourceLogs[0]!.scopeLogs[0]!.logRecords[0]!.eventName,
+ ).toBe("userale.raw.click");
+
+ fetchSpy.mockRestore();
+ });
+ });
+
+ describe("OtelSdkExporter", () => {
+ it("emits logs to a duck-typed OpenTelemetry JS Logs API Logger", async () => {
+ const mockLogger = {
+ emit: jest.fn(),
+ };
+
+ const exporter = new OtelSdkExporter(mockLogger);
+ const result = await exporter.export([
+ {
+ target: "input#search",
+ path: ["input#search", "body"],
+ pageUrl: "https://example.com",
+ type: "input",
+ logType: "raw",
+ clientTime: 1712000000000,
+ userAction: true,
+ details: { value: "flagon" },
+ },
+ ]);
+
+ expect(result.success).toBe(true);
+ expect(mockLogger.emit).toHaveBeenCalledTimes(1);
+ expect(mockLogger.emit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ timestamp: 1712000000000,
+ severityNumber: 9,
+ severityText: "INFO",
+ eventName: "userale.raw.input",
+ body: { value: "flagon" },
+ attributes: expect.objectContaining({
+ "event.name": "userale.raw.input",
+ "url.full": "https://example.com",
+ "flagon.target": "input#search",
+ }),
+ }),
+ );
+ });
+ });
});