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",
+          }),
+        }),
+      );
+    });
+  });
 });