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:43:45 UTC
(flagon) 01/01: docs(userale): add OpenTelemetry + Grafana/Loki/Tempo demo stack and auth/OTLP documentation (#141, #134, #101, #102)
This is an automated email from the ASF dual-hosted git repository.
brucearctor pushed a commit to branch feat/otel-demo-and-docs
in repository https://gitbox.apache.org/repos/asf/flagon.git
commit 6285aceda0b01516419197033621e1fa75855db1
Author: Bruce Arctor <50...@users.noreply.github.com>
AuthorDate: Sun Oct 11 08:42:41 2026 -0700
docs(userale): add OpenTelemetry + Grafana/Loki/Tempo demo stack and auth/OTLP documentation (#141, #134, #101, #102)
---
products/userale/example/otel-demo/README.md | 81 ++++++
.../userale/example/otel-demo/docker-compose.yml | 60 +++++
.../grafana/dashboards/userale-overview.json | 146 +++++++++++
.../provisioning/dashboards/dashboards.yaml | 28 +++
.../provisioning/datasources/datasources.yaml | 50 ++++
products/userale/example/otel-demo/index.html | 216 ++++++++++++++++
.../userale/example/otel-demo/loki-config.yaml | 62 +++++
.../example/otel-demo/otel-collector-config.yaml | 78 ++++++
.../userale/example/otel-demo/tempo-config.yaml | 43 ++++
products/userale/example/server/server.js | 36 ++-
products/userale/packages/flagon-userale/README.md | 280 ++++++++++++---------
11 files changed, 946 insertions(+), 134 deletions(-)
diff --git a/products/userale/example/otel-demo/README.md b/products/userale/example/otel-demo/README.md
new file mode 100644
index 0000000..1901b7b
--- /dev/null
+++ b/products/userale/example/otel-demo/README.md
@@ -0,0 +1,81 @@
+<!--
+ 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.
+-->
+
+# Apache Flagon UserALE.js — OpenTelemetry + Grafana/Loki/Tempo Demo
+
+This directory provides a zero-build, full-stack observability environment that ingests behavioral telemetry from **Apache Flagon UserALE.js** via OpenTelemetry (`OTLP/JSON`) into the **OpenTelemetry Collector**, stores logs in **Grafana Loki** and traces in **Grafana Tempo**, and visualizes both in **Grafana**.
+
+## Architecture
+
+```
+Browser (UserALE.js OtlpJsonExporter)
+ │ POST http://localhost:4318/v1/logs (OTLP/JSON + W3C traceId/spanId)
+ ▼
+OpenTelemetry Collector (otelcol-contrib:0.114.0)
+ ├──► Grafana Loki (:3100/otlp) ──┐
+ └──► Grafana Tempo (:4317) ──┼──► Grafana (:3000)
+ "Apache Flagon UserALE — Behavioral Telemetry & Trace Correlation"
+```
+
+## 5-Minute Quickstart
+
+1. **Build UserALE.js** (from `products/userale`):
+
+ ```bash
+ pnpm install
+ pnpm run build
+ ```
+
+2. **Start the OpenTelemetry Collector, Loki, Tempo, and Grafana stack**:
+
+ ```bash
+ docker compose -f products/userale/example/otel-demo/docker-compose.yml up -d
+ ```
+
+3. **Open the Demo Page & Grafana**:
+ - Open `products/userale/example/otel-demo/index.html` in your browser (or serve `products/userale` via any static server).
+ - Click buttons, type in the search input, and click **"Simulate Traced Checkout API Call"** to emit a UserALE behavioral log correlated with a W3C `traceId` and `spanId`.
+ - Open **Grafana** at [http://localhost:3000/d/flagon-userale-otel](http://localhost:3000/d/flagon-userale-otel) (no login required).
+ - Expand any log row with a `traceId` in the **Live UserALE OTLP Behavioral Stream** panel and click the **Tempo** link next to `TraceID` to jump directly from the user's DOM interaction to the backend distributed trace!
+
+4. **Tear Down**:
+ ```bash
+ docker compose -f products/userale/example/otel-demo/docker-compose.yml down
+ ```
+
+## CORS Configuration on the OpenTelemetry Collector
+
+Because UserALE.js ships telemetry directly from the browser (`Content-Type: application/json`, plus optional `Authorization` or `x-api-key` headers), your OpenTelemetry Collector `otlp` HTTP receiver must enable `cors`:
+
+```yaml
+receivers:
+ otlp:
+ protocols:
+ http:
+ endpoint: 0.0.0.0:4318
+ cors:
+ allowed_origins:
+ - "https://your-app.example.com"
+ allowed_headers:
+ - "Content-Type"
+ - "Authorization"
+ - "x-api-key"
+ - "traceparent"
+ - "tracestate"
+```
diff --git a/products/userale/example/otel-demo/docker-compose.yml b/products/userale/example/otel-demo/docker-compose.yml
new file mode 100644
index 0000000..2571445
--- /dev/null
+++ b/products/userale/example/otel-demo/docker-compose.yml
@@ -0,0 +1,60 @@
+# 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.
+
+services:
+ otel-collector:
+ image: otel/opentelemetry-collector-contrib:0.114.0
+ command: ["--config=/etc/otel-collector-config.yaml"]
+ volumes:
+ - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
+ ports:
+ - "4317:4317" # OTLP gRPC
+ - "4318:4318" # OTLP HTTP (/v1/logs, /v1/traces)
+ depends_on:
+ - loki
+ - tempo
+
+ loki:
+ image: grafana/loki:3.2.0
+ command: ["-config.file=/etc/loki/local-config.yaml"]
+ volumes:
+ - ./loki-config.yaml:/etc/loki/local-config.yaml:ro
+ ports:
+ - "3100:3100"
+
+ tempo:
+ image: grafana/tempo:2.6.0
+ command: ["-config.file=/etc/tempo.yaml"]
+ volumes:
+ - ./tempo-config.yaml:/etc/tempo.yaml:ro
+ ports:
+ - "3200:3200"
+
+ grafana:
+ image: grafana/grafana:11.3.0
+ environment:
+ - GF_AUTH_ANONYMOUS_ENABLED=true
+ - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
+ - GF_AUTH_DISABLE_LOGIN_FORM=true
+ volumes:
+ - ./grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
+ ports:
+ - "3000:3000"
+ depends_on:
+ - loki
+ - tempo
diff --git a/products/userale/example/otel-demo/grafana/dashboards/userale-overview.json b/products/userale/example/otel-demo/grafana/dashboards/userale-overview.json
new file mode 100644
index 0000000..3b17209
--- /dev/null
+++ b/products/userale/example/otel-demo/grafana/dashboards/userale-overview.json
@@ -0,0 +1,146 @@
+{
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "expr": "sum by (flagon_event_type) (count_over_time({service_name=~\".+\"} [$__auto]))",
+ "refId": "A"
+ }
+ ],
+ "title": "UserALE Interactions by Event Type",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "displayLabels": ["name", "percent"],
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "showLegend": true
+ },
+ "pieType": "donut"
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "expr": "topk(10, sum by (flagon_target) (count_over_time({service_name=~\".+\"} [$__range])))",
+ "refId": "A"
+ }
+ ],
+ "title": "Top 10 Interacted DOM Targets",
+ "type": "piechart"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "gridPos": {
+ "h": 14,
+ "w": 24,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "dedupStrategy": "none",
+ "enableLogDetails": true,
+ "prettifyLogMessage": true,
+ "showCommonLabels": false,
+ "showLabels": true,
+ "showTime": true,
+ "sortOrder": "Descending",
+ "wrapLogMessage": true
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "expr": "{service_name=~\".+\"}",
+ "refId": "A"
+ }
+ ],
+ "title": "Live UserALE OTLP Behavioral Stream (Click TraceID to jump to Tempo)",
+ "type": "logs"
+ }
+ ],
+ "refresh": "5s",
+ "schemaVersion": 39,
+ "tags": ["apache-flagon", "userale", "opentelemetry"],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-15m",
+ "to": "now"
+ },
+ "title": "Apache Flagon UserALE — Behavioral Telemetry & Trace Correlation",
+ "uid": "flagon-userale-otel",
+ "version": 1
+}
diff --git a/products/userale/example/otel-demo/grafana/provisioning/dashboards/dashboards.yaml b/products/userale/example/otel-demo/grafana/provisioning/dashboards/dashboards.yaml
new file mode 100644
index 0000000..1b5582a
--- /dev/null
+++ b/products/userale/example/otel-demo/grafana/provisioning/dashboards/dashboards.yaml
@@ -0,0 +1,28 @@
+# 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.
+
+apiVersion: 1
+
+providers:
+ - name: "Flagon UserALE Dashboards"
+ orgId: 1
+ folder: "Apache Flagon"
+ type: file
+ disableDeletion: false
+ editable: true
+ options:
+ path: /var/lib/grafana/dashboards
diff --git a/products/userale/example/otel-demo/grafana/provisioning/datasources/datasources.yaml b/products/userale/example/otel-demo/grafana/provisioning/datasources/datasources.yaml
new file mode 100644
index 0000000..cbeb1f4
--- /dev/null
+++ b/products/userale/example/otel-demo/grafana/provisioning/datasources/datasources.yaml
@@ -0,0 +1,50 @@
+# 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.
+
+apiVersion: 1
+
+datasources:
+ - name: Loki
+ uid: loki
+ type: loki
+ access: proxy
+ url: http://loki:3100
+ isDefault: true
+ jsonData:
+ derivedFields:
+ - datasourceUid: tempo
+ matcherRegex: '"traceId":"([0-9a-f]{32})"'
+ name: TraceID
+ url: "$${__value.raw}"
+ - datasourceUid: tempo
+ matcherType: label
+ matcherRegex: "trace_id"
+ name: TraceID
+ url: "$${__value.raw}"
+
+ - name: Tempo
+ uid: tempo
+ type: tempo
+ access: proxy
+ url: http://tempo:3200
+ jsonData:
+ tracesToLogsV2:
+ datasourceUid: loki
+ spanStartTimeShift: "-5s"
+ spanEndTimeShift: "5s"
+ filterByTraceID: true
+ filterBySpanID: false
diff --git a/products/userale/example/otel-demo/index.html b/products/userale/example/otel-demo/index.html
new file mode 100644
index 0000000..9b9c5c5
--- /dev/null
+++ b/products/userale/example/otel-demo/index.html
@@ -0,0 +1,216 @@
+<!--
+ ~ 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.
+-->
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>Apache Flagon UserALE + OpenTelemetry Demo</title>
+ <script
+ src="../../packages/flagon-userale/build/main.global.js"
+ data-otlp-endpoint="http://localhost:4318"
+ data-exporter="otlp"
+ data-tool="flagon-otel-demo"
+ data-version="2.5.0"
+ data-user="demo-user"
+ data-threshold="1"
+ data-interval="1000"
+ data-log-details="true"
+ ></script>
+ <style>
+ body {
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+ max-width: 780px;
+ margin: 2rem auto;
+ padding: 0 1rem;
+ line-height: 1.5;
+ }
+ .card {
+ border: 1px solid #d0d7de;
+ border-radius: 8px;
+ padding: 1.25rem;
+ margin-bottom: 1.25rem;
+ }
+ button {
+ padding: 0.6rem 1rem;
+ border-radius: 6px;
+ border: 1px solid #1f883d;
+ background: #1f883d;
+ color: #fff;
+ font-weight: 600;
+ cursor: pointer;
+ margin-right: 0.5rem;
+ }
+ button.secondary {
+ background: #0969da;
+ border-color: #0969da;
+ }
+ pre {
+ background: #f6f8fa;
+ padding: 0.75rem;
+ border-radius: 6px;
+ overflow-x: auto;
+ }
+ </style>
+ </head>
+ <body>
+ <h1>Apache Flagon UserALE.js — OpenTelemetry (OTLP/JSON) Demo</h1>
+ <p>
+ Every interaction on this page is exported directly to the OpenTelemetry
+ Collector at <code>http://localhost:4318/v1/logs</code> and correlated
+ with distributed traces in Grafana Loki & Tempo (<a
+ href="http://localhost:3000"
+ target="_blank"
+ rel="noreferrer"
+ >Open Grafana Dashboard</a
+ >).
+ </p>
+
+ <div class="card">
+ <h2>1. Standard DOM Interactions</h2>
+ <button id="add-to-cart-btn" type="button">Add Item to Cart</button>
+ <button id="Apply-Coupon-Btn" class="secondary" type="button">
+ Apply Discount Code
+ </button>
+ <p>
+ <label for="search-input">Search catalog: </label>
+ <input
+ id="search-input"
+ type="text"
+ placeholder="Type to generate input logs..."
+ />
+ </p>
+ </div>
+
+ <div class="card">
+ <h2>2. W3C Trace Context Correlation (Click-to-Trace)</h2>
+ <p>
+ Click below to generate a W3C <code>traceId</code> &
+ <code>spanId</code>, emit a simulated checkout span to
+ <code>http://localhost:4318/v1/traces</code>, and attach the active
+ <code>traceId</code> to the UserALE behavioral log.
+ </p>
+ <button id="traced-checkout-btn" type="button">
+ Simulate Traced Checkout API Call
+ </button>
+ <pre id="trace-status">No traced action triggered yet.</pre>
+ </div>
+
+ <script>
+ function randomHex(len) {
+ const bytes = new Uint8Array(len / 2);
+ window.crypto.getRandomValues(bytes);
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
+ "",
+ );
+ }
+
+ let activeTrace = null;
+ if (window.userale) {
+ window.userale.options({
+ traceContext: () => activeTrace,
+ });
+ }
+
+ document
+ .getElementById("traced-checkout-btn")
+ .addEventListener("click", async () => {
+ const traceId = randomHex(32);
+ const spanId = randomHex(16);
+ activeTrace = { traceId, spanId };
+
+ const nowNano = (BigInt(Date.now()) * BigInt(1000000)).toString(10);
+ const endNano = (BigInt(Date.now() + 45) * BigInt(1000000)).toString(
+ 10,
+ );
+
+ // Emit a custom UserALE event correlated with the active W3C traceId/spanId
+ if (window.userale) {
+ window.userale.packageCustomLog(
+ {
+ type: "checkout",
+ traceId,
+ spanId,
+ },
+ () => ({ cartTotal: 149.99, currency: "USD" }),
+ true,
+ );
+ }
+
+ // Emit a matching OTLP trace span to the OpenTelemetry Collector (/v1/traces)
+ const tracePayload = {
+ resourceSpans: [
+ {
+ resource: {
+ attributes: [
+ {
+ key: "service.name",
+ value: { stringValue: "flagon-otel-demo" },
+ },
+ ],
+ },
+ scopeSpans: [
+ {
+ scope: { name: "checkout-service", version: "1.0.0" },
+ spans: [
+ {
+ traceId,
+ spanId,
+ name: "POST /api/checkout",
+ kind: 3,
+ startTimeUnixNano: nowNano,
+ endTimeUnixNano: endNano,
+ attributes: [
+ {
+ key: "http.method",
+ value: { stringValue: "POST" },
+ },
+ {
+ key: "http.route",
+ value: { stringValue: "/api/checkout" },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ try {
+ await fetch("http://localhost:4318/v1/traces", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(tracePayload),
+ });
+ document.getElementById("trace-status").textContent =
+ `Sent correlated UserALE log + OTLP span!\ntraceId: ${traceId}\nspanId: ${spanId}`;
+ } catch (err) {
+ document.getElementById("trace-status").textContent =
+ `Generated traceId: ${traceId} (start docker compose to send to collector: ${err.message})`;
+ } finally {
+ setTimeout(() => {
+ activeTrace = null;
+ }, 250);
+ }
+ });
+ </script>
+ </body>
+</html>
diff --git a/products/userale/example/otel-demo/loki-config.yaml b/products/userale/example/otel-demo/loki-config.yaml
new file mode 100644
index 0000000..266a55d
--- /dev/null
+++ b/products/userale/example/otel-demo/loki-config.yaml
@@ -0,0 +1,62 @@
+# 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.
+
+auth_enabled: false
+
+server:
+ http_listen_port: 3100
+
+common:
+ instance_addr: 127.0.0.1
+ path_prefix: /tmp/loki
+ storage:
+ filesystem:
+ chunks_directory: /tmp/loki/chunks
+ rules_directory: /tmp/loki/rules
+ replication_factor: 1
+ ring:
+ kvstore:
+ store: inmemory
+
+schema_config:
+ configs:
+ - from: 2024-01-01
+ store: tsdb
+ object_store: filesystem
+ schema: v13
+ index:
+ prefix: index_
+ period: 24h
+
+limits_config:
+ allow_structured_metadata: true
+ otlp_config:
+ resource_attributes:
+ attributes_config:
+ - action: index_label
+ attributes:
+ - service.name
+ - session.id
+ log_attributes:
+ - action: structured_metadata
+ attributes:
+ - event.name
+ - flagon.log_type
+ - flagon.event_type
+ - flagon.target
+ - url.full
+ - user.id
diff --git a/products/userale/example/otel-demo/otel-collector-config.yaml b/products/userale/example/otel-demo/otel-collector-config.yaml
new file mode 100644
index 0000000..05b203d
--- /dev/null
+++ b/products/userale/example/otel-demo/otel-collector-config.yaml
@@ -0,0 +1,78 @@
+# 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.
+
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+ http:
+ endpoint: 0.0.0.0:4318
+ # Browser CORS configuration required when shipping logs directly from UserALE.js
+ cors:
+ allowed_origins:
+ - "*"
+ allowed_headers:
+ - "Content-Type"
+ - "Authorization"
+ - "x-api-key"
+ - "traceparent"
+ - "tracestate"
+ max_age: 7200
+
+processors:
+ batch:
+ timeout: 1s
+ send_batch_size: 512
+
+ # Optional OpenTelemetry Transformation Language (OTTL) processor showing how
+ # stock otelcol-contrib can enrich or normalize Flagon UserALE OTLP logs
+ # without requiring a custom Go receiver plugin.
+ transform/flagon:
+ error_mode: ignore
+ log_statements:
+ - context: log
+ statements:
+ - set(attributes["loki.attribute.labels"], "event.name, flagon.log_type, flagon.event_type, flagon.target, user.id")
+ - context: resource
+ statements:
+ - set(attributes["loki.resource.labels"], "service.name, session.id")
+
+exporters:
+ otlphttp/loki:
+ endpoint: http://loki:3100/otlp
+ tls:
+ insecure: true
+
+ otlp/tempo:
+ endpoint: tempo:4317
+ tls:
+ insecure: true
+
+ debug:
+ verbosity: normal
+
+service:
+ pipelines:
+ logs:
+ receivers: [otlp]
+ processors: [transform/flagon, batch]
+ exporters: [otlphttp/loki, debug]
+ traces:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [otlp/tempo, debug]
diff --git a/products/userale/example/otel-demo/tempo-config.yaml b/products/userale/example/otel-demo/tempo-config.yaml
new file mode 100644
index 0000000..f141510
--- /dev/null
+++ b/products/userale/example/otel-demo/tempo-config.yaml
@@ -0,0 +1,43 @@
+# 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.
+
+server:
+ http_listen_port: 3200
+
+distributor:
+ receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+ http:
+ endpoint: 0.0.0.0:4318
+
+ingester:
+ max_block_duration: 5m
+
+compactor:
+ compaction:
+ block_retention: 1h
+
+storage:
+ trace:
+ backend: local
+ wal:
+ path: /tmp/tempo/wal
+ local:
+ path: /tmp/tempo/blocks
diff --git a/products/userale/example/server/server.js b/products/userale/example/server/server.js
index 7ab21d6..d606ca9 100644
--- a/products/userale/example/server/server.js
+++ b/products/userale/example/server/server.js
@@ -62,7 +62,8 @@ app.use((req, res, next) => {
res.set({
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,PUT,POST,DELETE,OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type, Authorization, Content-Length, X-Requested-With",
+ "Access-Control-Allow-Headers":
+ "Content-Type, Authorization, Content-Length, X-Requested-With, x-api-key, traceparent, tracestate",
});
if (req.method === "OPTIONS") return res.sendStatus(200);
@@ -73,25 +74,40 @@ app.use(bodyParser.urlencoded({ extended: true, limit: "100mb" }));
app.use(bodyParser.json({ limit: "100mb" }));
app.use(bodyParser.text());
-app.use("/build", express.static(path.join(__dirname, "../../packages/flagon-userale/build")));
+app.use(
+ "/build",
+ express.static(path.join(__dirname, "../../packages/flagon-userale/build")),
+);
app.use("/", express.static(__dirname));
app.get("/", (req, res) => res.sendFile("index.html", { root: __dirname }));
-app.get("/ws", (req, res) => res.sendFile("ws-index.html", { root: __dirname }));
-app.get("/no-logging", (req, res) => res.sendFile("no-logging.html", { root: __dirname }));
-
-app.post("/", (req, res) => {
+app.get("/ws", (req, res) =>
+ res.sendFile("ws-index.html", { root: __dirname }),
+);
+app.get("/no-logging", (req, res) =>
+ res.sendFile("no-logging.html", { root: __dirname }),
+);
+
+app.post(["/", "/v1/logs"], (req, res) => {
const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
if (
(Array.isArray(body) && body.length === 0) ||
- (typeof body === "object" && body !== null && Object.keys(body).length === 0)
+ (typeof body === "object" &&
+ body !== null &&
+ Object.keys(body).length === 0)
) {
return res.sendStatus(200);
}
console.log("HTTP POST:", body);
- writeLogs(body);
+ if (Array.isArray(body)) {
+ writeLogs(body);
+ } else if (body && Array.isArray(body.logs)) {
+ writeLogs(body.logs);
+ } else if (body && Array.isArray(body.resourceLogs)) {
+ writeLogs([body]);
+ }
res.sendStatus(200);
});
@@ -124,7 +140,9 @@ wss.on("connection", (socket) => {
if (
(Array.isArray(body) && body.length === 0) ||
- (typeof body === "object" && body !== null && Object.keys(body).length === 0)
+ (typeof body === "object" &&
+ body !== null &&
+ Object.keys(body).length === 0)
) {
return;
}
diff --git a/products/userale/packages/flagon-userale/README.md b/products/userale/packages/flagon-userale/README.md
index 845b7bf..4e0b650 100644
--- a/products/userale/packages/flagon-userale/README.md
+++ b/products/userale/packages/flagon-userale/README.md
@@ -41,6 +41,7 @@ Additional documentation and a demonstration can be found at the [Apache Flagon
1. Visualize and analyze your logs. See our sample [kibana dahsboards](https://github.com/apache/flagon/tree/master/examples/elk/docker) for behavioral analytics.
## Table of Contents
+
[What's New](#whats-new-in-version-210)
[Installation](#installation)
[Configure](#configure)
@@ -65,23 +66,17 @@ See our [CHANGELOG](https://github.com/apache/flagon/blob/master/products/useral
Either through cloning our [source repo](https://github.com/apache/flagon) or by using npm:
```html
-npm install flagon-userale
-
-or
-
-npm install flagon-userale --engine-strict (enforces supported Node.js versions)
-
+npm install flagon-userale or npm install flagon-userale --engine-strict
+(enforces supported Node.js versions)
```
To include UserALE as an object in your project, include as a `module`:
```html
-import * as userale from 'flagon-userale';
-
-or
-
-const userale = require('flagon-userale');
+import * as userale from 'flagon-userale'; or const userale =
+require('flagon-userale');
```
+
Our [webpack example](https://github.com/apache/flagon/tree/master/products/userale/packages/flagon-userale/example/webpackUserAleExample) illustrates this use-case.
You can also include UserALE as a `script-tag`. A pre-built version of the userale script is included in our package and
@@ -90,6 +85,7 @@ repositories:
```html
<script src="./node_modules/flagon-userale/build/userale-2.4.0.min.js"></script>
```
+
Our [script tag example](https://github.com/apache/flagon/tree/master/products/userale/packages/flagon-userale/example) illustrates this use-case
If you include UserALE as a `script-tag`, consider installing via npm as a development dependency:
@@ -121,74 +117,132 @@ Configuration details follow:
If you have included UserALE in your project as a `module`, you will need to use our `userale.options()` function, which exposes library configuration options through our API.
-For example, if you do *not* want UserALE to start logging automatically, you can modify this behavior through the `userale.options()` API (`autostart` config).
+For example, if you do _not_ want UserALE to start logging automatically, you can modify this behavior through the `userale.options()` API (`autostart` config).
Then, you can use the `userale.start()` API export to begin logging at the appropriate time during page load or triggered from an event:
```html
-const changeMe = "me";
-userale.options({
- "userId": changeMe,
- "autostart": false,
- "url": "http://localhost:8000/",
- "version": "next",
- "logDetails": false,
- "sessionId": "this one"
-});
-
-userale.start();
-
+const changeMe = "me"; userale.options({ "userId": changeMe, "autostart": false,
+"url": "http://localhost:8000/", "version": "next", "logDetails": false,
+"sessionId": "this one" }); userale.start();
```
Additional examples of `userale.options()` can be found in our [example directory](https://github.com/apache/flagon/tree/master/products/userale/packages/flagon-userale/example).
The complete list of configurable parameters that can be configured via `userale.options()` is:
-| Param | Description | Default |
-|---|---|---|
-| url | Logging URL | http://localhost:8000 |
-| autostart | Should UserALE start on page load | true |
-| transmitInterval | Delay between transmit checks | 5000 (ms) |
-| logCountThreshold | Minimum number of logs to send | 5 |
-| userId | User identifier | null |
-| sessionId | Session identifier | null |
-| version | Application version identifier | null |
-| logDetails | Toggle detailed logs (keys pressed and input/change values) | false |
-| resolution | Delay between instances of high frequency logs (mouseover, scroll, etc.) | 500 (ms) |
-| userFromParams | Query param in the page URL to fetch userId from | null |
-| toolName | Name of tool being logged | null |
-| authHeader | Authorization header to be passed to logging endpoint | null |
+| Param | Description | Default |
+| ----------------- | --------------------------------------------------------------------------------------- | --------------------- |
+| url | Logging URL (`http://`, `https://`, `ws://`, or `wss://`) | http://localhost:8000 |
+| autostart | Should UserALE start on page load | true |
+| transmitInterval | Delay between transmit checks | 5000 (ms) |
+| logCountThreshold | Minimum number of logs to send | 5 |
+| userId | User identifier | null |
+| sessionId | Session identifier | null |
+| version | Application version identifier | null |
+| logDetails | Toggle detailed logs (keys pressed and input/change values) | false |
+| resolution | Delay between instances of high frequency logs (mouseover, scroll, etc.) | 500 (ms) |
+| userFromParams | Query param in the page URL to fetch userId from | null |
+| toolName | Name of tool being logged (`service.name` in OTLP) | null |
+| authHeader | `Authorization` header string or callback function | null |
+| apiKey | API key sent via the `x-api-key` HTTP request header | null |
+| headers | Custom HTTP headers object (`Record<string, string>`) | null |
+| exporterType | Wire format / transport (`'http-json'`, `'otlp'`, `'proto-json'`, `'websocket'`) | `'http-json'` |
+| otlpEndpoint | OpenTelemetry Collector HTTP endpoint (auto-appends `/v1/logs` when path is empty/root) | null |
+| exporter | Custom `LogExporter` instance overriding built-in exporters | null |
+| traceContext | Callback returning active W3C `{ traceId, spanId }` for OTLP correlation | null |
If you have included UserALE as a `script-tag` in your project, you can use HTML data parameters to pass configuration options to the library through the script tag. For example:
```html
- <script
- src="./node_modules/flagon-userale/build/userale-2.4.0.min.js"
- data-url="http://localhost:8000/"
- data-user="example-user"
- data-version="2.4.0"
- data-tool="Apache UserALE Example"
- ></script>
+<script
+ src="./node_modules/flagon-userale/build/main.global.js"
+ data-url="http://localhost:8000/"
+ data-user="example-user"
+ data-version="2.5.0"
+ data-tool="Apache UserALE Example"
+></script>
```
You have access to the same parameters listed above, however, naming conventions vary slightly for use in HTML:
-| Param | Description | Default |
-|---|---|---|
-| data-url | Logging URL | http://localhost:8000 |
-| data-autostart | Should UserALE start on page load | true |
-| data-interval | Delay between transmit checks | 5000 (ms) |
-| data-threshold | Minimum number of logs to send | 5 |
-| data-user | User identifier | null |
-| data-version | Application version identifier | null |
-| data-log-details | Toggle detailed logs (keys pressed and input/change values) | false |
-| data-resolution | Delay between instances of high frequency logs (mouseover, scroll, etc.) | 500 (ms) |
-| data-user-from-params | Query param in the page URL to fetch userId from | null |
-| data-tool | Name of tool being logged | null |
-| data-auth | Authorization header to be passed to logging endpoint | null |
+| Param | Description | Default |
+| --------------------- | ------------------------------------------------------------------------ | --------------------- |
+| data-url | Logging URL | http://localhost:8000 |
+| data-autostart | Should UserALE start on page load | true |
+| data-interval | Delay between transmit checks | 5000 (ms) |
+| data-threshold | Minimum number of logs to send | 5 |
+| data-user | User identifier | null |
+| data-version | Application version identifier | null |
+| data-log-details | Toggle detailed logs (keys pressed and input/change values) | false |
+| data-resolution | Delay between instances of high frequency logs (mouseover, scroll, etc.) | 500 (ms) |
+| data-user-from-params | Query param in the page URL to fetch userId from | null |
+| data-tool | Name of tool being logged | null |
+| data-auth | `Authorization` header value | null |
+| data-api-key | `x-api-key` header value | null |
+| data-headers | JSON-encoded object of custom HTTP headers | null |
+| data-exporter | Exporter format (`http-json`, `otlp`, `proto-json`, `websocket`) | `http-json` |
+| data-otlp-endpoint | OpenTelemetry Collector HTTP endpoint | null |
If you are using our [WebExtension](https://github.com/apache/flagon/blob/master/products/userale/packages/flagon-userale-ext/),
-you can modify some of these parameters via the extensions' `options` page.
+you can modify these parameters (including OAuth/API Key authentication and Exporter Format) via the extension's `Options` page.
+
+### Authentication, Custom Headers, CORS & WebSocket Limitations
+
+UserALE supports static and dynamic HTTP authentication headers across both interval transmissions and `pagehide` (`keepalive: true`) flushes:
+
+```js
+import * as userale from "flagon-userale";
+
+// 1. Static API key (sent as x-api-key header) or Authorization header
+userale.options({
+ url: "https://logs.example.com/ingest",
+ apiKey: "my-api-key",
+ authHeader: "Bearer my-jwt-token",
+});
+
+// 2. Dynamic token rotation via callback
+userale.registerAuthCallback(() => `Bearer ${getCurrentAccessToken()}`);
+```
+
+- **HTTPS Security**: Always use `https://` endpoints in production when `apiKey` or `authHeader` is configured. UserALE logs a console warning if credentials are configured over plaintext `http://` to a non-localhost host, and automatically strips sensitive `data-api-key`, `data-auth`, `data-token`, `data-secret`, and `data-password` attributes from captured DOM element logs.
+- **CORS Preflight Requirements**: Sending `Content-Type: application/json`, `Authorization`, or `x-api-key` from a browser triggers a CORS `OPTIONS` preflight request. Your ingestion server or OpenTelemetry Collector must include `Content-Type`, `Authorization`, and `x-api-key` in `Access-Control-Allow-Headers`.
+- **WebSocket (`ws://` / `wss://`) Limitation**: The standard browser `WebSocket` API does not allow setting custom HTTP headers (`Authorization` or `x-api-key`) during the WebSocket handshake. When streaming over WebSockets, authenticate via short-lived query parameters in `url` (`wss://logs.example.com/ws?token=...`) or same-site HTTP-only cookies.
+
+### OpenTelemetry (OTLP/JSON & OTel JS SDK) and Pluggable Exporters
+
+UserALE.js natively exports behavioral logs in **OpenTelemetry OTLP/JSON** (`ExportLogsServiceRequest`) or **Apache Flagon Protobuf v1 JSON** (`apache.flagon.userale.v1.LogBatch`) with zero external runtime dependencies:
+
+```js
+import * as userale from "flagon-userale";
+
+// Export directly to an OpenTelemetry Collector (/v1/logs is appended automatically)
+userale.options({
+ exporterType: "otlp",
+ otlpEndpoint: "http://localhost:4318",
+ toolName: "checkout-web",
+ toolVersion: "1.4.0",
+ // Optional: correlate DOM events with active W3C TraceContext spans
+ traceContext: () => ({
+ traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
+ spanId: "00f067aa0ba902b7",
+ }),
+});
+```
+
+If your application already initializes the OpenTelemetry JS Logs SDK (`@opentelemetry/sdk-logs`), bridge UserALE into your existing `Logger` using `OtelSdkExporter`:
+
+```js
+import * as userale from "flagon-userale";
+import { logs } from "@opentelemetry/api-logs";
+
+const otelLogger = logs.getLogger("userale");
+userale.options({
+ exporter: new userale.OtelSdkExporter(otelLogger),
+});
+```
+
+See [`example/otel-demo`](https://github.com/apache/flagon/tree/master/products/userale/example/otel-demo) for a complete Docker Compose stack with **OpenTelemetry Collector**, **Grafana Loki**, **Grafana Tempo**, and a pre-built **Grafana Dashboard** featuring click-to-trace correlation.
## Usage
@@ -198,21 +252,21 @@ We have exposed a number of functions that assist you in modifying, filtering, a
A complete list of available functions are as follows:
-| Function | Description | Notes |
-|---|---|---|
-| userale.options | modify userale's configuration option | see top level README for complete list of options |
-| [DEPRECATED] userale.filter | filters out logs from logging queue by keys or values | filters are callbacks with global scope |
-| [DEPRECATED] userale.map | modify/add log keys or values | mappings are callbacks with global scope |
-| userale.addCallbacks | add one or more callbacks to be executed during log packaging | callbacks have global scope |
-| userale.removeCallbacks | remove one or more callbacks by name | Removes callbacks added from userale.addCallbacks |
-| userale.log | appends a custom log to the log queue | the custom log object is an object key:value pairs |
-| userale.packageLog | transforms the provided event into a log and appends it to the log queue | designed for HTML events |
-| userale.packageCustomLog | packages the provided customLog to include standard meta data and appends it to the log queue | designed for non HTML events|
-| userale.details | defines the way information is extracted from various events | supports packageLog/packageCustomLog 'details' |
-| userale.getSelector | builds a string CSS selector from the provided HTML element id | populates 'target' field in packaged logs |
-| userale.buildPath| builds an array of elements from the provided event target, to the root element (DOM path) | populates the 'path' field in packaged logs |
-| userale.start | used to start the logging process if | unecessary if 'autostart' is set to true in initial setting (default) |
-| userale.stop | halts the logging process. Logs will no longer be sent | will need to invoke userale.start to restart logging |
+| Function | Description | Notes |
+| --------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| userale.options | modify userale's configuration option | see top level README for complete list of options |
+| [DEPRECATED] userale.filter | filters out logs from logging queue by keys or values | filters are callbacks with global scope |
+| [DEPRECATED] userale.map | modify/add log keys or values | mappings are callbacks with global scope |
+| userale.addCallbacks | add one or more callbacks to be executed during log packaging | callbacks have global scope |
+| userale.removeCallbacks | remove one or more callbacks by name | Removes callbacks added from userale.addCallbacks |
+| userale.log | appends a custom log to the log queue | the custom log object is an object key:value pairs |
+| userale.packageLog | transforms the provided event into a log and appends it to the log queue | designed for HTML events |
+| userale.packageCustomLog | packages the provided customLog to include standard meta data and appends it to the log queue | designed for non HTML events |
+| userale.details | defines the way information is extracted from various events | supports packageLog/packageCustomLog 'details' |
+| userale.getSelector | builds a string CSS selector from the provided HTML element id | populates 'target' field in packaged logs |
+| userale.buildPath | builds an array of elements from the provided event target, to the root element (DOM path) | populates the 'path' field in packaged logs |
+| userale.start | used to start the logging process if | unecessary if 'autostart' is set to true in initial setting (default) |
+| userale.stop | halts the logging process. Logs will no longer be sent | will need to invoke userale.start to restart logging |
Including UserALE as a `script-tag` provides you access to the same functions listed above. However, UserALE essentially
becomes a property of the DOM. As such, you'll need to call functions as a window property:
@@ -231,24 +285,18 @@ Select examples are below:
Filter your logs with `userale.filter`:
```html
-userale.filter(function (log) {
- var type_array = ['mouseup', 'mouseover', 'mousedown', 'keydown', 'dblclick', 'blur', 'focus', 'input', 'wheel'];
- var logType_array = ['interval'];
- return !type_array.includes(log.type) && !logType_array.includes(log.logType);
-});
+userale.filter(function (log) { var type_array = ['mouseup', 'mouseover',
+'mousedown', 'keydown', 'dblclick', 'blur', 'focus', 'input', 'wheel']; var
+logType_array = ['interval']; return !type_array.includes(log.type) &&
+!logType_array.includes(log.logType); });
```
Modify (add/remove) log fields with surgical precision using `userale.map`:
```html
-userale.map(function (log) {
- var targetsForLabels = ["button#test_button"];
- if (targetsForLabels.includes(log.target)) {
- return Object.assign({}, log, { CustomLabel: "Click me!" });
- } else {
- return log;
- }
- });
+userale.map(function (log) { var targetsForLabels = ["button#test_button"]; if
+(targetsForLabels.includes(log.target)) { return Object.assign({}, log, {
+CustomLabel: "Click me!" }); } else { return log; } });
```
(Additional examples illustrate [precision custom labeling](https://github.com/apache/flagon/tree/master/products/userale/packages/flagon-userale/example/log-label-example), using a variety of functions.)
@@ -256,48 +304,27 @@ userale.map(function (log) {
Generate custom logs with `userale.log`:
```html
-document.addEventListener('change', function(e) {
- if (e.target.value === 'log') {
- userale.log({
- target: userale.getSelector(e.target),
- path: userale.buildPath(e),
- type: e.type,
- logType: 'custom',
- userAction: false,
- details: 'I can make this log look like anything I want',
- customField1: 'foo',
- customField2: 'bar',
- userId: userale.options().userId,
- toolVersion: userale.options().version,
- toolName: userale.options().toolName,
- useraleVersion: userale.options().useraleVersion,
- sessionId: userale.options().sessionId,
- customLabel: "(custom) Log Example"
- });
- }
-});
+document.addEventListener('change', function(e) { if (e.target.value === 'log')
+{ userale.log({ target: userale.getSelector(e.target), path:
+userale.buildPath(e), type: e.type, logType: 'custom', userAction: false,
+details: 'I can make this log look like anything I want', customField1: 'foo',
+customField2: 'bar', userId: userale.options().userId, toolVersion:
+userale.options().version, toolName: userale.options().toolName, useraleVersion:
+userale.options().useraleVersion, sessionId: userale.options().sessionId,
+customLabel: "(custom) Log Example" }); } });
```
User our own log packaging pipeline to streamline custom HTML event logging with `userale.packageLog`:
```html
-document.addEventListener('change', function(e){
- if (e.target.value === 'packageLog') {
- /**You can then use the 'Mapping' API function to modify custom logs created with the packageLog function*/
- userale.map(function (log) {
- var targetsForLabels = ['change'];
- if (targetsForLabels.includes(log.type)) {
- return Object.assign({}, log, { logType: 'custom', customLabel: 'packageLog Example' });
- } else {
- return log;
- }
- });
- /**You can also use the details function to package additional log meta data, or add custom details*/
- userale.packageLog(e, userale.details(userale.options(),'change'));
- } else {
- return false
- }
-});
+document.addEventListener('change', function(e){ if (e.target.value ===
+'packageLog') { /**You can then use the 'Mapping' API function to modify custom
+logs created with the packageLog function*/ userale.map(function (log) { var
+targetsForLabels = ['change']; if (targetsForLabels.includes(log.type)) { return
+Object.assign({}, log, { logType: 'custom', customLabel: 'packageLog Example'
+}); } else { return log; } }); /**You can also use the details function to
+package additional log meta data, or add custom details*/ userale.packageLog(e,
+userale.details(userale.options(),'change')); } else { return false } });
```
Again, see [Usage](#usage) for differences in invoking these functions with `module` and `script-tag` includes.
@@ -322,11 +349,13 @@ npm run build
```
To run UserALE unit tests:
+
```
npm run test
```
We use gulp-mocha for unit tests. The results will print to your terminal:
+
```
...
attachHandlers
@@ -339,13 +368,14 @@ We use gulp-mocha for unit tests. The results will print to your terminal:
45 passing (954ms)
1 pending
```
+
Any failing tests will also be logged in the terminal. If there are failing tests, please consider [submitting an issue report](https://github.com/apache/flagon/issues).
For more guidance on modifying Flagon UserALE src code, check out [the guide on our website](http://flagon.apache.org/docs/useralejs/modifying/).
## Contributing
-Contributions are welcome! Simply [submit an issue](https://github.com/apache/flagon/issues). Pull requests are welcome. The core team will review it and work with you to incorporate it into UserALE. If you want to become a contributor to the project, see our [contribution guide](http://flagon.apache.org/docs/contributing/).
+Contributions are welcome! Simply [submit an issue](https://github.com/apache/flagon/issues). Pull requests are welcome. The core team will review it and work with you to incorporate it into UserALE. If you want to become a contributor to the project, see our [contribution guide](http://flagon.apache.org/docs/contributing/).
Join the conversation: tell us your needs, wishes, and interests by joining our [mailing list](dev-subscribe@flagon.apache.org)!