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:09:41 UTC

(flagon) branch feat/log-exporter created (now c989957)

This is an automated email from the ASF dual-hosted git repository.

brucearctor pushed a change to branch feat/log-exporter
in repository https://gitbox.apache.org/repos/asf/flagon.git


      at c989957  feat(userale): add pluggable LogExporter architecture, unified headers, and ProtoJsonExporter (#133, #139, #142, #143)

This branch includes the following new commits:

     new c989957  feat(userale): add pluggable LogExporter architecture, unified headers, and ProtoJsonExporter (#133, #139, #142, #143)

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(flagon) 01/01: feat(userale): add pluggable LogExporter architecture, unified headers, and ProtoJsonExporter (#133, #139, #142, #143)

Posted by au...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

brucearctor pushed a commit to branch feat/log-exporter
in repository https://gitbox.apache.org/repos/asf/flagon.git

commit c989957f3b5e2cbb067a6a2a1204fc5c34f9f521
Author: Bruce Arctor <50...@users.noreply.github.com>
AuthorDate: Sun Oct 11 08:09:01 2026 -0700

    feat(userale): add pluggable LogExporter architecture, unified headers, and ProtoJsonExporter (#133, #139, #142, #143)
---
 .../packages/flagon-userale/src/configure.ts       |   2 +
 .../flagon-userale/src/exporters/headers.ts        |  54 ++++
 .../flagon-userale/src/exporters/http-json.ts      | 126 ++++++++
 .../packages/flagon-userale/src/exporters/index.ts |  71 +++++
 .../flagon-userale/src/exporters/proto-json.ts     | 219 +++++++++++++
 .../packages/flagon-userale/src/exporters/types.ts |  40 +++
 .../flagon-userale/src/exporters/websocket.ts      |  48 +++
 .../flagon-userale/src/getInitialSettings.ts       |   7 +
 .../userale/packages/flagon-userale/src/main.ts    |  15 +
 .../packages/flagon-userale/src/sendLogs.ts        | 155 +++------
 .../userale/packages/flagon-userale/src/types.d.ts |  14 +-
 .../flagon-userale/test/spec/exporters.spec.ts     | 349 +++++++++++++++++++++
 .../flagon-userale/test/spec/sendLogs.spec.ts      |  41 ++-
 13 files changed, 1034 insertions(+), 107 deletions(-)

diff --git a/products/userale/packages/flagon-userale/src/configure.ts b/products/userale/packages/flagon-userale/src/configure.ts
index 6e42776..0261521 100644
--- a/products/userale/packages/flagon-userale/src/configure.ts
+++ b/products/userale/packages/flagon-userale/src/configure.ts
@@ -30,6 +30,8 @@ export class Configuration {
   public authHeader: Settings.AuthHeader = null;
   public browserSessionId: Settings.SessionId = null;
   public custIndex: Settings.CustomIndex = null;
+  public exporter: Settings.CustomExporter = null;
+  public exporterType: Settings.ExporterType = "http-json";
   public headers: Settings.Headers = null;
   public httpSessionId: Settings.SessionId = null;
   public logCountThreshold: number = 0;
diff --git a/products/userale/packages/flagon-userale/src/exporters/headers.ts b/products/userale/packages/flagon-userale/src/exporters/headers.ts
new file mode 100644
index 0000000..a1443b2
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/headers.ts
@@ -0,0 +1,54 @@
+/*!
+ * 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 { updateAuthHeader, updateCustomHeaders } from "@/utils";
+
+/**
+ * Builds the HTTP headers for outgoing log requests, unifying
+ * Authorization (static or callback), x-api-key, and custom headers.
+ * @param  {Configuration} config Configuration singleton.
+ * @return {Headers}              Constructed Fetch Headers instance.
+ */
+export function buildHeaders(config: Configuration): Headers {
+  const headers = new Headers({
+    "Content-Type": "application/json;charset=UTF-8",
+  });
+
+  updateAuthHeader(config);
+  if (config.authHeader) {
+    const authHeaderValue =
+      typeof config.authHeader === "function"
+        ? config.authHeader()
+        : String(config.authHeader);
+    headers.set("Authorization", authHeaderValue);
+  }
+
+  if (config.apiKey) {
+    headers.set("x-api-key", config.apiKey);
+  }
+
+  // Update custom headers last to allow them to over-write the defaults.
+  updateCustomHeaders(config);
+  if (config.headers) {
+    for (const [header, value] of Object.entries(config.headers)) {
+      headers.set(header, value);
+    }
+  }
+
+  return headers;
+}
diff --git a/products/userale/packages/flagon-userale/src/exporters/http-json.ts b/products/userale/packages/flagon-userale/src/exporters/http-json.ts
new file mode 100644
index 0000000..79379e4
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/http-json.ts
@@ -0,0 +1,126 @@
+/*!
+ * 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 { buildHeaders } from "./headers";
+import type { ExportOptions, ExportResult, LogExporter } from "./types";
+
+const MAX_KEEPALIVE_BYTES = 48 * 1024;
+
+/**
+ * Splits a log array into sub-batches whose serialized JSON size stays under
+ * MAX_KEEPALIVE_BYTES so browser pagehide/keepalive requests do not reject.
+ */
+export function chunkLogsForKeepalive<T>(
+  logs: Array<T>,
+  serialize: (batch: Array<T>) => string,
+): Array<string> {
+  const full = serialize(logs);
+  if (full.length <= MAX_KEEPALIVE_BYTES || logs.length <= 1) {
+    return [full];
+  }
+  const mid = Math.ceil(logs.length / 2);
+  return [
+    ...chunkLogsForKeepalive(logs.slice(0, mid), serialize),
+    ...chunkLogsForKeepalive(logs.slice(mid), serialize),
+  ];
+}
+
+export class HttpJsonExporter implements LogExporter {
+  private config: Configuration;
+
+  constructor(config: Configuration) {
+    this.config = config;
+  }
+
+  protected serialize(logs: Array<Logging.Log>): string {
+    return JSON.stringify(logs);
+  }
+
+  protected getEndpoint(): string {
+    return this.config.url;
+  }
+
+  public async export(
+    logs: Array<Logging.Log>,
+    options?: ExportOptions,
+  ): Promise<ExportResult> {
+    const headers = buildHeaders(this.config);
+    const endpoint = this.getEndpoint();
+    const keepalive = Boolean(options?.keepalive);
+    const retries = options?.retries ?? 0;
+
+    if (keepalive) {
+      const payloads = chunkLogsForKeepalive(logs, (batch) =>
+        this.serialize(batch),
+      );
+      try {
+        await Promise.all(
+          payloads.map((body) =>
+            fetch(endpoint, {
+              keepalive: true,
+              method: "POST",
+              headers,
+              body,
+            }),
+          ),
+        );
+        return { success: true };
+      } catch (err) {
+        const error = err instanceof Error ? err : new Error(String(err));
+        console.error(error);
+        return { success: false, error };
+      }
+    }
+
+    const body = this.serialize(logs);
+
+    async function attemptSend(remainingRetries: number): Promise<void> {
+      try {
+        const response = await fetch(endpoint, {
+          method: "POST",
+          headers,
+          body,
+        });
+
+        if (!response.ok) {
+          if (remainingRetries > 0) {
+            return attemptSend(remainingRetries - 1);
+          }
+          throw new Error(`Failed to send logs: ${response.statusText}`);
+        }
+      } catch (error) {
+        if (remainingRetries > 0) {
+          return attemptSend(remainingRetries - 1);
+        }
+        throw error;
+      }
+    }
+
+    await attemptSend(retries);
+    return { success: true };
+  }
+
+  public async flush(): Promise<void> {
+    return Promise.resolve();
+  }
+
+  public async shutdown(): Promise<void> {
+    return Promise.resolve();
+  }
+}
diff --git a/products/userale/packages/flagon-userale/src/exporters/index.ts b/products/userale/packages/flagon-userale/src/exporters/index.ts
new file mode 100644
index 0000000..c0721ac
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/index.ts
@@ -0,0 +1,71 @@
+/*!
+ * 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 { buildHeaders } from "./headers";
+import { HttpJsonExporter, chunkLogsForKeepalive } from "./http-json";
+import {
+  ProtoJsonExporter,
+  toProtoLogBatch,
+  toProtoUserALELog,
+} from "./proto-json";
+import type {
+  ExportOptions,
+  ExportResult,
+  ExporterType,
+  LogExporter,
+} from "./types";
+import { WebSocketExporter } from "./websocket";
+
+export {
+  buildHeaders,
+  chunkLogsForKeepalive,
+  HttpJsonExporter,
+  ProtoJsonExporter,
+  toProtoLogBatch,
+  toProtoUserALELog,
+  WebSocketExporter,
+};
+export type { ExportOptions, ExportResult, ExporterType, LogExporter };
+
+/**
+ * Factory that creates the appropriate LogExporter for the given configuration.
+ */
+export function createExporter(config: Configuration): LogExporter {
+  if (config.exporter) {
+    return config.exporter;
+  }
+
+  try {
+    const url = new URL(config.url);
+    if (
+      url.protocol === "ws:" ||
+      url.protocol === "wss:" ||
+      config.exporterType === "websocket"
+    ) {
+      return new WebSocketExporter(config);
+    }
+  } catch {
+    // Fallback to HTTP exporter if config.url is not a full URL (e.g. relative path in tests)
+  }
+
+  if (config.exporterType === "proto-json") {
+    return new ProtoJsonExporter(config);
+  }
+
+  return new HttpJsonExporter(config);
+}
diff --git a/products/userale/packages/flagon-userale/src/exporters/proto-json.ts b/products/userale/packages/flagon-userale/src/exporters/proto-json.ts
new file mode 100644
index 0000000..45a31e7
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/proto-json.ts
@@ -0,0 +1,219 @@
+/*!
+ * 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 { HttpJsonExporter } from "./http-json";
+
+const KNOWN_CUSTOM_LOG_KEYS = new Set([
+  "pageUrl",
+  "pageTitle",
+  "pageReferrer",
+  "userAgent",
+  "clientTime",
+  "scrnRes",
+  "logType",
+  "userAction",
+  "details",
+  "userId",
+  "toolVersion",
+  "toolName",
+  "useraleVersion",
+  "sessionId",
+  "httpSessionId",
+  "browserSessionId",
+]);
+
+function buildProtoMetadata(log: Logging.Log): Logging.JSONObject {
+  const scrnRes = log.scrnRes as
+    { width?: number; height?: number } | undefined;
+  const metadata: Logging.JSONObject = {
+    pageUrl: typeof log.pageUrl === "string" ? log.pageUrl : "",
+    pageTitle: typeof log.pageTitle === "string" ? log.pageTitle : "",
+    pageReferrer: typeof log.pageReferrer === "string" ? log.pageReferrer : "",
+    userAgent: typeof log.userAgent === "string" ? log.userAgent : "",
+    clientTime: String(
+      typeof log.clientTime === "number"
+        ? Math.trunc(log.clientTime)
+        : typeof log.startTime === "number"
+          ? Math.trunc(log.startTime)
+          : 0,
+    ),
+  };
+
+  if (scrnRes && typeof scrnRes === "object") {
+    metadata.screenResolution = {
+      width: typeof scrnRes.width === "number" ? Math.trunc(scrnRes.width) : 0,
+      height:
+        typeof scrnRes.height === "number" ? Math.trunc(scrnRes.height) : 0,
+    };
+  }
+
+  if (typeof log.userId === "string") metadata.userId = log.userId;
+  if (typeof log.sessionId === "string") metadata.sessionId = log.sessionId;
+  if (typeof log.httpSessionId === "string")
+    metadata.httpSessionId = log.httpSessionId;
+  if (typeof log.browserSessionId === "string")
+    metadata.browserSessionId = log.browserSessionId;
+  if (typeof log.toolName === "string") metadata.toolName = log.toolName;
+  if (typeof log.toolVersion === "string")
+    metadata.toolVersion = log.toolVersion;
+  if (typeof log.useraleVersion === "string")
+    metadata.useraleVersion = log.useraleVersion;
+
+  return metadata;
+}
+
+function toStringAttributes(
+  attrs: unknown,
+): Record<string, string> | undefined {
+  if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) {
+    return undefined;
+  }
+  const out: Record<string, string> = {};
+  for (const [k, v] of Object.entries(attrs)) {
+    out[k] = typeof v === "string" ? v : JSON.stringify(v);
+  }
+  return out;
+}
+
+/**
+ * Converts a flat UserALE log entry into the proto3 canonical JSON structure
+ * defined by apache.flagon.userale.v1.UserALELog in log.proto (#142, #143).
+ */
+export function toProtoUserALELog(log: Logging.Log): Logging.JSONObject {
+  const metadata = buildProtoMetadata(log);
+  const logType = log.logType;
+
+  if (logType === "raw") {
+    const loc = log.location as
+      { x?: number | null; y?: number | null } | undefined;
+    const location: Logging.JSONObject = {};
+    if (loc && typeof loc === "object") {
+      if (typeof loc.x === "number") location.x = loc.x;
+      if (typeof loc.y === "number") location.y = loc.y;
+    }
+
+    const raw: Logging.JSONObject = {
+      metadata,
+      target: typeof log.target === "string" ? log.target : "",
+      path: Array.isArray(log.path) ? (log.path as string[]) : [],
+      eventType: typeof log.type === "string" ? log.type : "",
+      location,
+      userAction: Boolean(log.userAction),
+    };
+
+    if (typeof log.microTime === "number") {
+      raw.microTime = log.microTime;
+    }
+    if (
+      log.details &&
+      typeof log.details === "object" &&
+      !Array.isArray(log.details)
+    ) {
+      raw.details = log.details as Logging.JSONObject;
+    }
+    const strAttrs = toStringAttributes(log.attributes);
+    if (strAttrs) {
+      raw.attributes = strAttrs;
+    }
+    if (
+      log.style &&
+      typeof log.style === "object" &&
+      !Array.isArray(log.style)
+    ) {
+      raw.style = log.style as Logging.JSONObject;
+    }
+
+    return {
+      logType: "LOG_TYPE_RAW",
+      raw,
+    };
+  }
+
+  if (logType === "interval") {
+    const interval: Logging.JSONObject = {
+      metadata,
+      target: typeof log.target === "string" ? log.target : "",
+      path: Array.isArray(log.path) ? (log.path as string[]) : [],
+      eventType: typeof log.type === "string" ? log.type : "",
+      count: typeof log.count === "number" ? Math.trunc(log.count) : 0,
+      duration: String(
+        typeof log.duration === "number" ? Math.trunc(log.duration) : 0,
+      ),
+      startTime: String(
+        typeof log.startTime === "number" ? Math.trunc(log.startTime) : 0,
+      ),
+      endTime: String(
+        typeof log.endTime === "number" ? Math.trunc(log.endTime) : 0,
+      ),
+      targetChange: Boolean(log.targetChange),
+      typeChange: Boolean(log.typeChange),
+      userAction: Boolean(log.userAction),
+    };
+
+    return {
+      logType: "LOG_TYPE_INTERVAL",
+      interval,
+    };
+  }
+
+  const customFields: Logging.JSONObject = {};
+  for (const [key, value] of Object.entries(log)) {
+    if (!KNOWN_CUSTOM_LOG_KEYS.has(key) && value !== undefined) {
+      customFields[key] = value;
+    }
+  }
+
+  const custom: Logging.JSONObject = {
+    metadata,
+    userAction: Boolean(log.userAction),
+    customFields,
+  };
+
+  if (
+    log.details &&
+    typeof log.details === "object" &&
+    !Array.isArray(log.details)
+  ) {
+    custom.details = log.details as Logging.JSONObject;
+  }
+
+  return {
+    logType: "LOG_TYPE_CUSTOM",
+    custom,
+  };
+}
+
+/**
+ * Converts an array of UserALE logs into an apache.flagon.userale.v1.LogBatch
+ * proto3 JSON object.
+ */
+export function toProtoLogBatch(logs: Array<Logging.Log>): Logging.JSONObject {
+  return {
+    logs: logs.map(toProtoUserALELog),
+  };
+}
+
+/**
+ * Exporter that serializes UserALE logs using the apache.flagon.userale.v1
+ * Protobuf JSON schema (#142, #143) with zero runtime protobuf dependencies.
+ */
+export class ProtoJsonExporter extends HttpJsonExporter {
+  protected override serialize(logs: Array<Logging.Log>): string {
+    return JSON.stringify(toProtoLogBatch(logs));
+  }
+}
diff --git a/products/userale/packages/flagon-userale/src/exporters/types.ts b/products/userale/packages/flagon-userale/src/exporters/types.ts
new file mode 100644
index 0000000..5a1ebcb
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/types.ts
@@ -0,0 +1,40 @@
+/*!
+ * 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";
+
+export type ExporterType =
+  "http-json" | "proto-json" | "otlp" | "websocket" | "custom";
+
+export interface ExportOptions {
+  keepalive?: boolean;
+  retries?: number;
+}
+
+export interface ExportResult {
+  success: boolean;
+  error?: Error;
+}
+
+export interface LogExporter {
+  export(
+    logs: Array<Logging.Log>,
+    options?: ExportOptions,
+  ): Promise<ExportResult>;
+  flush?(): Promise<void>;
+  shutdown?(): Promise<void>;
+}
diff --git a/products/userale/packages/flagon-userale/src/exporters/websocket.ts b/products/userale/packages/flagon-userale/src/exporters/websocket.ts
new file mode 100644
index 0000000..6132778
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/exporters/websocket.ts
@@ -0,0 +1,48 @@
+/*!
+ * 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 type { ExportResult, LogExporter } from "./types";
+
+export class WebSocketExporter implements LogExporter {
+  private wsock: WebSocket;
+
+  constructor(config: Configuration) {
+    this.wsock = new WebSocket(config.url);
+  }
+
+  public async export(logs: Array<Logging.Log>): Promise<ExportResult> {
+    const data = JSON.stringify(logs);
+    this.wsock.send(data);
+    return { success: true };
+  }
+
+  public async flush(): Promise<void> {
+    return Promise.resolve();
+  }
+
+  public async shutdown(): Promise<void> {
+    if (
+      typeof WebSocket !== "undefined" &&
+      this.wsock.readyState === WebSocket.OPEN
+    ) {
+      this.wsock.close();
+    }
+    return Promise.resolve();
+  }
+}
diff --git a/products/userale/packages/flagon-userale/src/getInitialSettings.ts b/products/userale/packages/flagon-userale/src/getInitialSettings.ts
index 85e3d78..14118db 100644
--- a/products/userale/packages/flagon-userale/src/getInitialSettings.ts
+++ b/products/userale/packages/flagon-userale/src/getInitialSettings.ts
@@ -38,6 +38,8 @@ export function getInitialSettings(): Settings.Config {
       autostart: true,
       browserSessionId: null,
       custIndex: null,
+      exporter: null,
+      exporterType: "http-json",
       headers: null,
       httpSessionId: null,
       logCountThreshold: +5,
@@ -83,12 +85,17 @@ export function getInitialSettings(): Settings.Config {
         return null;
       };
   const headers = get("data-headers");
+  const exporterAttr = (get("data-exporter-type") ||
+    get("data-exporter") ||
+    "http-json") as Settings.ExporterType;
   const settings: Settings.Config = {
     apiKey: get("data-api-key") || null,
     authHeader: get("data-auth") || null,
     autostart: get("data-autostart") === "false" ? false : true,
     browserSessionId: null,
     custIndex: get("data-index") || null,
+    exporter: null,
+    exporterType: exporterAttr,
     headers: headers ? JSON.parse(headers) : null,
     httpSessionId: httpSessionId,
     logCountThreshold: +(get("data-threshold") || 5),
diff --git a/products/userale/packages/flagon-userale/src/main.ts b/products/userale/packages/flagon-userale/src/main.ts
index dae8393..4934776 100644
--- a/products/userale/packages/flagon-userale/src/main.ts
+++ b/products/userale/packages/flagon-userale/src/main.ts
@@ -55,6 +55,21 @@ export {
   getSelector as getSelector,
   buildPath as buildPath,
 } from "@/packageLogs";
+export {
+  buildHeaders,
+  createExporter,
+  HttpJsonExporter,
+  ProtoJsonExporter,
+  toProtoLogBatch,
+  toProtoUserALELog,
+  WebSocketExporter,
+} from "@/exporters";
+export type {
+  ExportOptions,
+  ExportResult,
+  ExporterType,
+  LogExporter,
+} from "@/exporters";
 export type { Logging, Settings } from "@/types";
 
 config.update({
diff --git a/products/userale/packages/flagon-userale/src/sendLogs.ts b/products/userale/packages/flagon-userale/src/sendLogs.ts
index 29cba74..f62b200 100644
--- a/products/userale/packages/flagon-userale/src/sendLogs.ts
+++ b/products/userale/packages/flagon-userale/src/sendLogs.ts
@@ -16,11 +16,30 @@
  */
 
 import { Configuration } from "@/configure";
+import { buildHeaders, createExporter, type LogExporter } from "@/exporters";
 import { Logging } from "@/types";
-import { updateAuthHeader, updateCustomHeaders } from "@/utils";
+
+export { buildHeaders };
 
 let sendIntervalId: string | number | NodeJS.Timeout | undefined;
-let wsock: WebSocket;
+let activeExporter: LogExporter | null = null;
+let activeExporterKey: string | null = null;
+let activeCustomExporter: LogExporter | null = null;
+
+function resolveExporter(config: Configuration): LogExporter {
+  const key = `${config.url}|${config.exporterType ?? "http-json"}`;
+  if (
+    activeExporter &&
+    activeExporterKey === key &&
+    activeCustomExporter === (config.exporter ?? null)
+  ) {
+    return activeExporter;
+  }
+  activeExporter = createExporter(config);
+  activeExporterKey = key;
+  activeCustomExporter = config.exporter ?? null;
+  return activeExporter;
+}
 
 /**
  * Initializes the log queue processors.
@@ -32,20 +51,24 @@ export function initSender(logs: Array<Logging.Log>, config: Configuration) {
     clearInterval(sendIntervalId);
   }
 
-  const url = new URL(config.url);
-  if (
-    (config.apiKey || config.authHeader) &&
-    url.protocol === "http:" &&
-    !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
-  ) {
-    console.warn(
-      "[UserALE] Sending credentials over plaintext HTTP. Use HTTPS in production.",
-    );
-  }
-  if (url.protocol === "ws:" || url.protocol === "wss:") {
-    wsock = new WebSocket(config.url);
+  try {
+    const url = new URL(config.url);
+    if (
+      (config.apiKey || config.authHeader) &&
+      url.protocol === "http:" &&
+      !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
+    ) {
+      console.warn(
+        "[UserALE] Sending credentials over plaintext HTTP. Use HTTPS in production.",
+      );
+    }
+  } catch {
+    // Ignore invalid URL during initialization if a custom exporter is used
   }
 
+  activeExporter = null;
+  resolveExporter(config);
+
   sendIntervalId = sendOnInterval(logs, config);
   sendOnClose(logs, config);
 }
@@ -73,11 +96,11 @@ export function sendOnInterval(
   }, config.transmitInterval);
 }
 
-// /**
-//  * Attempts to flush the remaining logs when the window is closed.
-//  * @param  {Array<Logging.Log>} logs   Array of logs to be flushed.
-//  * @param  {Configuration} config Configuration singleton to be read from.
-//  */
+/**
+ * Attempts to flush the remaining logs when the window is closed.
+ * @param  {Array<Logging.Log>} logs   Array of logs to be flushed.
+ * @param  {Configuration} config Configuration singleton to be read from.
+ */
 export function sendOnClose(
   logs: Array<Logging.Log>,
   config: Configuration,
@@ -88,39 +111,18 @@ export function sendOnClose(
     }
 
     if (logs.length > 0) {
-      const url = new URL(config.url);
-
-      if (url.protocol === "ws:" || url.protocol === "wss:") {
-        const data = JSON.stringify(logs);
-        wsock.send(data);
-      } else {
-        const headers: HeadersInit = new Headers();
-        headers.set("Content-Type", "application/json;charset=UTF-8");
-
-        if (config.authHeader) {
-          headers.set("Authorization", config.authHeader.toString());
-        }
-
-        if (config.apiKey) {
-          headers.set("x-api-key", config.apiKey);
-        }
-
-        fetch(config.url, {
-          keepalive: true,
-          method: "POST",
-          headers: headers,
-          body: JSON.stringify(logs),
-        }).catch((error) => {
-          console.error(error);
-        });
-      }
+      const batch = logs.slice(0);
       logs.splice(0); // clear log queue
+      const exporter = resolveExporter(config);
+      exporter.export(batch, { keepalive: true }).catch((error) => {
+        console.error(error);
+      });
     }
   });
 }
 
 /**
- * Sends the provided array of logs to the specified url,
+ * Sends the provided array of logs to the specified url via the active exporter,
  * retrying the request up to the specified number of retries.
  * @param  {Array<Logging.Log>} logs    Array of logs to send.
  * @param  {Configuration} config     configuration singleton.
@@ -131,63 +133,6 @@ export async function sendLogs(
   config: Configuration,
   retries: number,
 ): Promise<void> {
-  const data = JSON.stringify(logs);
-  const url = new URL(config.url);
-
-  if (url.protocol === "ws:" || url.protocol === "wss:") {
-    wsock.send(data);
-    return;
-  }
-
-  // Build headers
-  const headers = new Headers({
-    "Content-Type": "application/json;charset=UTF-8",
-  });
-
-  updateAuthHeader(config);
-  if (config.authHeader) {
-    const authHeaderValue =
-      typeof config.authHeader === "function"
-        ? config.authHeader()
-        : config.authHeader;
-    headers.set("Authorization", authHeaderValue);
-  }
-
-  if (config.apiKey) {
-    headers.set("x-api-key", config.apiKey);
-  }
-
-  // Update custom headers last to allow them to over-write the defaults. This assumes
-  // the user knows what they are doing and may want to over-write the defaults.
-  updateCustomHeaders(config);
-  if (config.headers) {
-    for (const [header, value] of Object.entries(config.headers)) {
-      headers.set(header, value);
-    }
-  }
-
-  async function attemptSend(remainingRetries: number): Promise<void> {
-    try {
-      const response = await fetch(config.url, {
-        method: "POST",
-        headers,
-        body: data,
-      });
-
-      if (!response.ok) {
-        if (remainingRetries > 0) {
-          return attemptSend(remainingRetries - 1);
-        } else {
-          throw new Error(`Failed to send logs: ${response.statusText}`);
-        }
-      }
-    } catch (error) {
-      if (remainingRetries > 0) {
-        return attemptSend(remainingRetries - 1);
-      }
-      throw error;
-    }
-  }
-
-  return attemptSend(retries);
+  const exporter = resolveExporter(config);
+  await exporter.export(logs, { retries });
 }
diff --git a/products/userale/packages/flagon-userale/src/types.d.ts b/products/userale/packages/flagon-userale/src/types.d.ts
index 75c2af6..d7c0d35 100644
--- a/products/userale/packages/flagon-userale/src/types.d.ts
+++ b/products/userale/packages/flagon-userale/src/types.d.ts
@@ -14,6 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import type {
+  ExporterType as ExporterMode,
+  LogExporter,
+} from "@/exporters/types";
+
 export declare namespace Settings {
   type Version = string | null;
   type UserId = string | null;
@@ -25,11 +30,14 @@ export declare namespace Settings {
   type CustomIndex = string | null;
   type HeaderObject = { [key: string]: string };
   type Headers = HeaderObject | null;
+  type ExporterType = ExporterMode;
+  type CustomExporter = LogExporter | null;
   type ConfigValueTypes =
     | string
     | number
     | boolean
     | null
+    | undefined
     | Version
     | UserId
     | SessionId
@@ -38,7 +46,9 @@ export declare namespace Settings {
     | AuthHeader
     | ApiKey
     | CustomIndex
-    | Headers;
+    | Headers
+    | ExporterType
+    | CustomExporter;
 
   type TimeFunction = (() => number) | ((ts: number) => number);
 
@@ -52,6 +62,8 @@ export declare namespace Settings {
     authHeader: AuthHeader;
     browserSessionId: SessionId;
     custIndex: CustomIndex;
+    exporter?: CustomExporter;
+    exporterType?: ExporterType;
     headers: Headers;
     httpSessionId: SessionId;
     logCountThreshold: number;
diff --git a/products/userale/packages/flagon-userale/test/spec/exporters.spec.ts b/products/userale/packages/flagon-userale/test/spec/exporters.spec.ts
new file mode 100644
index 0000000..008ccc3
--- /dev/null
+++ b/products/userale/packages/flagon-userale/test/spec/exporters.spec.ts
@@ -0,0 +1,349 @@
+/**
+ * @jest-environment jsdom
+ */
+/*
+ * 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 "whatwg-fetch";
+import { Configuration } from "@/configure";
+import {
+  buildHeaders,
+  chunkLogsForKeepalive,
+  createExporter,
+  HttpJsonExporter,
+  ProtoJsonExporter,
+  toProtoLogBatch,
+  WebSocketExporter,
+} from "@/exporters";
+import { LogBatchSchema, LogType } from "@/gen/apache/flagon/userale/v1/log_pb";
+import { initSender, sendOnInterval } from "@/sendLogs";
+import {
+  registerAuthCallback,
+  registerHeadersCallback,
+  resetAuthCallback,
+  resetHeadersCallback,
+} from "@/utils";
+import { fromJson } from "@bufbuild/protobuf";
+
+import type { LogExporter } from "@/exporters";
+import type { Logging } from "@/types";
+import type { JsonValue } from "@bufbuild/protobuf";
+
+const config = Configuration.getInstance();
+
+describe("exporters", () => {
+  beforeEach(() => {
+    config.reset();
+    resetAuthCallback();
+    resetHeadersCallback();
+  });
+
+  describe("buildHeaders", () => {
+    it("merges string authHeader, custom headers callback, and apiKey", () => {
+      registerHeadersCallback(() => ({
+        "x-custom": "val",
+      }));
+      config.update({
+        authHeader: "Bearer test-token",
+        apiKey: "api-key-123",
+      });
+
+      const headers = buildHeaders(config);
+      expect(headers.get("Content-Type")).toBe(
+        "application/json;charset=UTF-8",
+      );
+      expect(headers.get("Authorization")).toBe("Bearer test-token");
+      expect(headers.get("x-api-key")).toBe("api-key-123");
+      expect(headers.get("x-custom")).toBe("val");
+    });
+
+    it("supports callback authHeader", () => {
+      registerAuthCallback(() => "Bearer dynamic-token");
+      const headers = buildHeaders(config);
+      expect(headers.get("Authorization")).toBe("Bearer dynamic-token");
+    });
+  });
+
+  describe("createExporter factory", () => {
+    it("creates HttpJsonExporter by default for http URLs", () => {
+      config.update({ url: "http://localhost:8000" });
+      const exporter = createExporter(config);
+      expect(exporter).toBeInstanceOf(HttpJsonExporter);
+    });
+
+    it("creates WebSocketExporter for ws:// or wss:// URLs", () => {
+      const wsSpy = jest.spyOn(global, "WebSocket").mockImplementation(
+        () =>
+          ({
+            readyState: 1,
+            send: jest.fn(),
+            close: jest.fn(),
+          }) as unknown as WebSocket,
+      );
+      config.update({ url: "ws://localhost:8000" });
+      const exporter = createExporter(config);
+      expect(exporter).toBeInstanceOf(WebSocketExporter);
+      wsSpy.mockRestore();
+    });
+
+    it("creates ProtoJsonExporter when exporterType is 'proto-json'", () => {
+      config.update({
+        url: "http://localhost:8000",
+        exporterType: "proto-json",
+      });
+      const exporter = createExporter(config);
+      expect(exporter).toBeInstanceOf(ProtoJsonExporter);
+    });
+
+    it("returns custom config.exporter when provided", () => {
+      const customExporter: LogExporter = {
+        export: jest.fn().mockResolvedValue({ success: true }),
+      };
+      config.update({ exporter: customExporter });
+      const exporter = createExporter(config);
+      expect(exporter).toBe(customExporter);
+    });
+
+    it("routes sendOnInterval through custom config.exporter", () => {
+      const customExporter: LogExporter = {
+        export: jest.fn().mockResolvedValue({ success: true }),
+      };
+      config.update({
+        on: true,
+        transmitInterval: 100,
+        logCountThreshold: 1,
+        exporter: customExporter,
+      });
+
+      const logs: Array<Logging.Log> = [{ target: "button#submit" }];
+      jest.useFakeTimers();
+      initSender(logs, config);
+      sendOnInterval(logs, config);
+
+      jest.advanceTimersByTime(100);
+      expect(customExporter.export).toHaveBeenCalledWith(
+        [{ target: "button#submit" }],
+        { retries: 0 },
+      );
+      jest.useRealTimers();
+    });
+  });
+
+  describe("chunkLogsForKeepalive", () => {
+    it("keeps small batches in a single chunk", () => {
+      const logs = [{ id: 1 }, { id: 2 }];
+      const chunks = chunkLogsForKeepalive(logs, (batch) =>
+        JSON.stringify(batch),
+      );
+      expect(chunks).toHaveLength(1);
+      expect(JSON.parse(chunks[0]!)).toEqual(logs);
+    });
+
+    it("splits batches that exceed MAX_KEEPALIVE_BYTES into multiple chunks", () => {
+      const logs = Array.from({ length: 10 }, (_, i) => ({
+        id: i,
+        payload: "x".repeat(8000),
+      }));
+      const chunks = chunkLogsForKeepalive(logs, (batch) =>
+        JSON.stringify(batch),
+      );
+      expect(chunks.length).toBeGreaterThan(1);
+      const reassembled = chunks.flatMap(
+        (chunk) => JSON.parse(chunk) as typeof logs,
+      );
+      expect(reassembled).toEqual(logs);
+    });
+  });
+
+  describe("ProtoJsonExporter & Protobuf v1 contract (#142, #143)", () => {
+    it("serializes raw, interval, and custom logs into valid apache.flagon.userale.v1.LogBatch JSON", () => {
+      const rawLog: Logging.Log = {
+        target: "button#buy",
+        path: ["button#buy", "div.card", "body", "html"],
+        pageUrl: "https://shop.example.com/product/42",
+        pageTitle: "Shop Example",
+        pageReferrer: "https://shop.example.com/",
+        browser: { browser: "chrome", version: "125.0" },
+        userAgent: "Mozilla/5.0 Chrome/125.0",
+        clientTime: 1712000000000,
+        scrnRes: { width: 1920, height: 1080 },
+        microTime: 123.45,
+        location: { x: 150, y: 320 },
+        type: "click",
+        logType: "raw",
+        userAction: true,
+        details: { button: 0, ctrl: false },
+        userId: "user-42",
+        toolVersion: "1.2.3",
+        toolName: "shop-web",
+        useraleVersion: "2.5.0",
+        sessionId: "sess-abc",
+        httpSessionId: "http-sess-1",
+        browserSessionId: "browser-sess-1",
+        attributes: { id: "buy", class: "btn primary" },
+        style: { color: "rgb(255, 255, 255)" },
+      };
+
+      const intervalLog: Logging.Log = {
+        target: "div.feed",
+        path: ["div.feed", "body", "html"],
+        pageUrl: "https://shop.example.com/product/42",
+        pageTitle: "Shop Example",
+        pageReferrer: "https://shop.example.com/",
+        userAgent: "Mozilla/5.0 Chrome/125.0",
+        count: 14,
+        duration: 480,
+        startTime: 1712000000000,
+        endTime: 1712000000480,
+        type: "mouseover",
+        logType: "interval",
+        targetChange: false,
+        typeChange: false,
+        userAction: false,
+        userId: "user-42",
+        toolVersion: "1.2.3",
+        toolName: "shop-web",
+        useraleVersion: "2.5.0",
+        sessionId: "sess-abc",
+        httpSessionId: "http-sess-1",
+        browserSessionId: "browser-sess-1",
+      };
+
+      const customLog: Logging.Log = {
+        pageUrl: "https://shop.example.com/product/42",
+        pageTitle: "Shop Example",
+        pageReferrer: "https://shop.example.com/",
+        userAgent: "Mozilla/5.0 Chrome/125.0",
+        clientTime: 1712000001000,
+        scrnRes: { width: 1920, height: 1080 },
+        logType: "custom",
+        userAction: true,
+        details: { checkoutStep: 2 },
+        customLabel: "checkout_started",
+        cartValue: 99.95,
+        userId: "user-42",
+        toolVersion: "1.2.3",
+        toolName: "shop-web",
+        useraleVersion: "2.5.0",
+        sessionId: "sess-abc",
+        httpSessionId: "http-sess-1",
+        browserSessionId: "browser-sess-1",
+      };
+
+      const protoBatchJson = toProtoLogBatch([rawLog, intervalLog, customLog]);
+
+      // Validate canonical Proto3 JSON deserialization against @bufbuild/protobuf LogBatchSchema
+      const decoded = fromJson(
+        LogBatchSchema,
+        protoBatchJson as unknown as JsonValue,
+      );
+      expect(decoded.logs).toHaveLength(3);
+
+      // 1. Raw log validation
+      const first = decoded.logs[0]!;
+      expect(first.logType).toBe(LogType.RAW);
+      expect(first.log.case).toBe("raw");
+      if (first.log.case === "raw") {
+        expect(first.log.value.target).toBe("button#buy");
+        expect(first.log.value.eventType).toBe("click");
+        expect(first.log.value.path).toEqual([
+          "button#buy",
+          "div.card",
+          "body",
+          "html",
+        ]);
+        expect(first.log.value.location?.x).toBe(150);
+        expect(first.log.value.location?.y).toBe(320);
+        expect(first.log.value.microTime).toBe(123.45);
+        expect(first.log.value.userAction).toBe(true);
+        expect(first.log.value.metadata?.pageUrl).toBe(
+          "https://shop.example.com/product/42",
+        );
+        expect(first.log.value.metadata?.clientTime).toBe(
+          BigInt(1712000000000),
+        );
+        expect(first.log.value.metadata?.screenResolution?.width).toBe(1920);
+        expect(first.log.value.metadata?.userId).toBe("user-42");
+        expect(first.log.value.details).toEqual({
+          button: 0,
+          ctrl: false,
+        });
+        expect(first.log.value.attributes).toEqual({
+          id: "buy",
+          class: "btn primary",
+        });
+      }
+
+      // 2. Interval log validation
+      const second = decoded.logs[1]!;
+      expect(second.logType).toBe(LogType.INTERVAL);
+      expect(second.log.case).toBe("interval");
+      if (second.log.case === "interval") {
+        expect(second.log.value.target).toBe("div.feed");
+        expect(second.log.value.count).toBe(14);
+        expect(second.log.value.duration).toBe(BigInt(480));
+        expect(second.log.value.startTime).toBe(BigInt(1712000000000));
+        expect(second.log.value.endTime).toBe(BigInt(1712000000480));
+      }
+
+      // 3. Custom log validation
+      const third = decoded.logs[2]!;
+      expect(third.logType).toBe(LogType.CUSTOM);
+      expect(third.log.case).toBe("custom");
+      if (third.log.case === "custom") {
+        expect(third.log.value.userAction).toBe(true);
+        expect(third.log.value.details).toEqual({ checkoutStep: 2 });
+        expect(third.log.value.customFields).toEqual({
+          customLabel: "checkout_started",
+          cartValue: 99.95,
+        });
+      }
+    });
+
+    it("exports LogBatch JSON over fetch when ProtoJsonExporter.export is called", async () => {
+      const fetchSpy = jest
+        .spyOn(global, "fetch")
+        .mockResolvedValue(new Response(null, { status: 200 }));
+
+      config.update({
+        url: "http://localhost:8000",
+        exporterType: "proto-json",
+      });
+
+      const exporter = new ProtoJsonExporter(config);
+      const result = await exporter.export([
+        {
+          target: "a#nav",
+          path: ["a#nav"],
+          type: "click",
+          logType: "raw",
+          clientTime: 1712000000000,
+          userAction: true,
+        },
+      ]);
+
+      expect(result.success).toBe(true);
+      expect(fetchSpy).toHaveBeenCalledTimes(1);
+      const bodyStr = fetchSpy.mock.calls[0]![1]!.body as string;
+      const parsed = JSON.parse(bodyStr) as JsonValue;
+      const decoded = fromJson(LogBatchSchema, parsed);
+      expect(decoded.logs).toHaveLength(1);
+      expect(decoded.logs[0]!.logType).toBe(LogType.RAW);
+
+      fetchSpy.mockRestore();
+    });
+  });
+});
diff --git a/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts b/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts
index 8d73234..bad6e83 100644
--- a/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts
+++ b/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts
@@ -20,7 +20,12 @@
 import "whatwg-fetch";
 import { Configuration } from "@/configure";
 import { initSender, sendOnInterval, sendOnClose } from "@/sendLogs";
-import { registerAuthCallback, registerHeadersCallback } from "@/utils";
+import {
+  registerAuthCallback,
+  registerHeadersCallback,
+  resetAuthCallback,
+  resetHeadersCallback,
+} from "@/utils";
 
 import type { Logging } from "@/types";
 
@@ -30,6 +35,8 @@ let xhrMock: Partial<XMLHttpRequest>;
 describe("sendLogs", () => {
   beforeEach(() => {
     config.reset();
+    resetAuthCallback();
+    resetHeadersCallback();
     xhrMock = {
       open: jest.fn(),
       send: jest.fn(),
@@ -300,4 +307,36 @@ describe("sendLogs", () => {
 
     warnSpy.mockRestore();
   });
+
+  it("includes callback authHeader and custom headers on pagehide (#133)", () => {
+    const fetchSpy = jest
+      .spyOn(global, "fetch")
+      .mockResolvedValue(new Response());
+
+    const authCallback = jest.fn().mockReturnValue("Bearer callback-token");
+    const headersCallback = jest.fn().mockReturnValue({
+      "x-custom-header": "custom-val",
+    });
+    registerAuthCallback(authCallback);
+    registerHeadersCallback(headersCallback);
+
+    config.update({
+      on: true,
+      url: "http://test.com",
+      apiKey: "key-123",
+    });
+
+    sendOnClose([{ foo: "bar" }], config);
+    global.window.dispatchEvent(new window.CustomEvent("pagehide"));
+
+    expect(fetchSpy).toHaveBeenCalled();
+    const lastCall = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1]!;
+    const callHeaders = lastCall[1]!.headers as Headers;
+    expect(callHeaders.get("Authorization")).toBe("Bearer callback-token");
+    expect(callHeaders.get("x-custom-header")).toBe("custom-val");
+    expect(callHeaders.get("x-api-key")).toBe("key-123");
+    expect(lastCall[1]!.keepalive).toBe(true);
+
+    fetchSpy.mockRestore();
+  });
 });