Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193e6c6e39 | ||
|
|
7b68f60202 | ||
|
|
51a8412ae1 | ||
|
|
601f382af6 | ||
|
|
71184e0823 | ||
|
|
e046bd86d1 |
No files matched your search
+26
-34
@@ -1,6 +1,9 @@
|
|||||||
name: Build and Publish
|
name: Build and Publish
|
||||||
run-name: ${{ gitea.actor }} build 🚀
|
run-name: ${{ gitea.actor }} build 🚀
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Metadata:
|
Metadata:
|
||||||
@@ -26,7 +29,7 @@ jobs:
|
|||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
BuildAndDeployAmd64:
|
BuildAndPushAmd64:
|
||||||
needs: Metadata
|
needs: Metadata
|
||||||
runs-on: amd64
|
runs-on: amd64
|
||||||
steps:
|
steps:
|
||||||
@@ -39,15 +42,32 @@ jobs:
|
|||||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
|
-t matst80/otel-debug:${{ needs.Metadata.outputs.version }} \
|
||||||
-t matst80/otel-debug:latest \
|
-t matst80/otel-debug:latest \
|
||||||
.
|
.
|
||||||
- name: Push amd64 images
|
- name: Push images
|
||||||
run: |
|
run: |
|
||||||
|
docker push matst80/otel-debug:${{ needs.Metadata.outputs.version }}
|
||||||
docker push matst80/otel-debug:latest
|
docker push matst80/otel-debug:latest
|
||||||
- name: Rollout amd64 otel deployment
|
- name: Reconcile after image is published
|
||||||
|
env:
|
||||||
|
GITOPS_WEBHOOK_SECRET: ${{ secrets.GITOPS_WEBHOOK_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
kubectl rollout restart deployment/otel-debug-receiver -n monitoring
|
if [ -z "$GITOPS_WEBHOOK_SECRET" ]; then
|
||||||
kubectl rollout status deployment/otel-debug-receiver -n monitoring
|
echo "GITOPS_WEBHOOK_SECRET must match the k8s-helper Gitea webhook secret" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
payload="$(mktemp)"
|
||||||
|
trap 'rm -f "$payload"' EXIT
|
||||||
|
printf '{"ref":"%s","after":"%s","repository":{"clone_url":"https://git.k6n.net/mats/go-otel.git","ssh_url":"git@git.k6n.net:mats/go-otel.git"}}' \
|
||||||
|
"$GITHUB_REF" "$GITHUB_SHA" > "$payload"
|
||||||
|
signature="$(openssl dgst -sha256 -hmac "$GITOPS_WEBHOOK_SECRET" "$payload" | awk '{print $NF}')"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: $signature" \
|
||||||
|
--data-binary "@$payload" \
|
||||||
|
https://ui.k6n.net/api/gitops/webhooks/gitea
|
||||||
- name: Upload source maps
|
- name: Upload source maps
|
||||||
env:
|
env:
|
||||||
RELEASE: ${{ needs.Metadata.outputs.version }}
|
RELEASE: ${{ needs.Metadata.outputs.version }}
|
||||||
@@ -69,31 +89,3 @@ jobs:
|
|||||||
done < <(find "$maps_dir" -type f -name '*.js.map' -print0)
|
done < <(find "$maps_dir" -type f -name '*.js.map' -print0)
|
||||||
rm -rf "$maps_dir"
|
rm -rf "$maps_dir"
|
||||||
[ "$found" = 1 ] || { echo "no source maps found in UI build" >&2; exit 1; }
|
[ "$found" = 1 ] || { echo "no source maps found in UI build" >&2; exit 1; }
|
||||||
# - name: Rollout amd64 deployment (pin to version)
|
|
||||||
# run: |
|
|
||||||
# kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-otel-debug-amd64:${{ needs.Metadata.outputs.version }}
|
|
||||||
# # kubectl rollout status deployment/cart-actor-x86 -n cart
|
|
||||||
|
|
||||||
# BuildAndDeployArm64:
|
|
||||||
# needs: Metadata
|
|
||||||
# runs-on: arm64
|
|
||||||
# steps:
|
|
||||||
# - uses: actions/checkout@v4
|
|
||||||
# - name: Build arm64 image
|
|
||||||
# run: |
|
|
||||||
# docker build \
|
|
||||||
# --build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
|
||||||
# --build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
|
||||||
# --build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
|
||||||
# --progress=plain \
|
|
||||||
# -t registry.knatofs.se/go-otel-debug:latest \
|
|
||||||
# -t registry.knatofs.se/go-otel-debug:${{ needs.Metadata.outputs.version }} \
|
|
||||||
# .
|
|
||||||
# - name: Push arm64 images
|
|
||||||
# run: |
|
|
||||||
# docker push registry.knatofs.se/go-otel-debug:latest
|
|
||||||
# docker push registry.knatofs.se/go-otel-debug:${{ needs.Metadata.outputs.version }}
|
|
||||||
# - name: Rollout arm64 deployment (pin to version)
|
|
||||||
# run: |
|
|
||||||
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-otel-debug:${{ needs.Metadata.outputs.version }}
|
|
||||||
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
|
|
||||||
@@ -6,6 +6,9 @@ otel-agent.log
|
|||||||
/otel-ui/node_modules/
|
/otel-ui/node_modules/
|
||||||
/otel-ui/dist/
|
/otel-ui/dist/
|
||||||
/otel-ui/dist-ssr/
|
/otel-ui/dist-ssr/
|
||||||
|
/rum/node_modules/
|
||||||
|
/static/otel-rum.js.map
|
||||||
|
/static/otel-todo.js.map
|
||||||
|
|
||||||
# Local credentials and scratch
|
# Local credentials and scratch
|
||||||
.env
|
.env
|
||||||
|
|||||||
+11
-1
@@ -15,6 +15,15 @@ COPY otel-ui/ .
|
|||||||
|
|
||||||
RUN npm install && npm run build
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
FROM node:24-alpine AS rum-builder
|
||||||
|
|
||||||
|
WORKDIR /app/rum
|
||||||
|
COPY rum/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY rum/ ./
|
||||||
|
COPY scripts/otel-rum.test.mjs /app/scripts/otel-rum.test.mjs
|
||||||
|
RUN npm test
|
||||||
|
|
||||||
# Go build stage
|
# Go build stage
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS go-builder
|
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS go-builder
|
||||||
|
|
||||||
@@ -46,13 +55,14 @@ WORKDIR /home/appuser/
|
|||||||
COPY --from=go-builder /go-otel .
|
COPY --from=go-builder /go-otel .
|
||||||
COPY --from=go-builder /app/otel-ui/dist ./dist
|
COPY --from=go-builder /app/otel-ui/dist ./dist
|
||||||
COPY static/ ./static
|
COPY static/ ./static
|
||||||
|
COPY --from=rum-builder /app/static/ ./static/
|
||||||
COPY db/GeoLite2-City.mmdb db/GeoLite2-ASN.mmdb ./db/
|
COPY db/GeoLite2-City.mmdb db/GeoLite2-ASN.mmdb ./db/
|
||||||
COPY agents_example.md .
|
COPY agents_example.md .
|
||||||
COPY instrumentation.md .
|
COPY instrumentation.md .
|
||||||
|
|
||||||
# Private server-side maps are the default. Staging/dev can opt into public
|
# Private server-side maps are the default. Staging/dev can opt into public
|
||||||
# maps for Chrome DevTools with --build-arg PUBLIC_SOURCEMAPS=true.
|
# maps for Chrome DevTools with --build-arg PUBLIC_SOURCEMAPS=true.
|
||||||
RUN if [ "${PUBLIC_SOURCEMAPS}" != "true" ]; then find ./dist -type f -name '*.map' -delete; fi
|
RUN if [ "${PUBLIC_SOURCEMAPS}" != "true" ]; then find ./dist ./static -type f -name '*.map' -delete; fi
|
||||||
|
|
||||||
# Ensure the appuser owns the files
|
# Ensure the appuser owns the files
|
||||||
RUN chown -R appuser:appgroup /home/appuser/
|
RUN chown -R appuser:appgroup /home/appuser/
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
.PHONY: lint install-lint
|
.PHONY: build build-rum test-rum lint install-lint
|
||||||
|
|
||||||
|
build: build-rum
|
||||||
|
go build -o server ./cmd/server
|
||||||
|
|
||||||
|
build-rum:
|
||||||
|
npm --prefix rum ci
|
||||||
|
npm --prefix rum run build
|
||||||
|
|
||||||
|
test-rum:
|
||||||
|
npm --prefix rum ci
|
||||||
|
npm --prefix rum test
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@which golangci-lint > /dev/null || (echo "golangci-lint not found, installing..." && make install-lint)
|
@which golangci-lint > /dev/null || (echo "golangci-lint not found, installing..." && make install-lint)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"enabled":true,"dryRun":false,"channels":["telegram"],"lastRun":"2026-09-25T18:56:43.3470721Z","alerts":[{"id":"tlxng1-nginx","at":"2026-09-25T18:31:13.347300133Z","spike":{"service":"nginx","windowStart":"2026-09-25T18:26:13.347300133Z","windowEnd":"2026-09-25T18:31:13.347300133Z","errorCount":69,"baselineMean":14.03448275862069,"excess":4.9655172413793105,"patterns":[{"signature":"3c59646ff131a8ccb8f4d1931f3536112cac2eb59d5a5dbdcbe3f08873d23f0d","count":4,"baselineMean":0.034482758620689655,"excess":3.9655172413793105,"novel":false,"clients":1,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:31:11.346792393Z","Service":"nginx","Release":"","Kind":"span","Source":"backend","IsError":true,"StatusClass":"","DurationMS":0,"TraceID":"0751de83a2f0ce211f2b7ab1a64d8aba","PatternSignature":"3c59646ff131a8ccb8f4d1931f3536112cac2eb59d5a5dbdcbe3f08873d23f0d","IssueFingerprint":"","IsRetry":false,"IsTimeout":false,"IsCancellation":false,"Message":"","SpanName":"","Route":"/","Host":"auth.k6n.net","StatusCode":503,"ExceptionType":"","ClientIP":"34.177.93.136","UAClass":"browser","IsDuplicate":false,"IsRootCause":true,"HasUpstream":false,"TraceServices":["nginx"],"BadActor":""}},{"signature":"fb485cd240e1dab0261891dc14170112bbc37c90189f276b45935afdedf534cb","count":1,"baselineMean":0,"excess":1,"novel":false,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:31:06.252746363Z","Service":"nginx","Release":"","Kind":"span","Source":"backend","IsError":true,"StatusClass":"","DurationMS":0,"TraceID":"d05925e26f581fcba99d165ea5928114","PatternSignature":"fb485cd240e1dab0261891dc14170112bbc37c90189f276b45935afdedf534cb","IssueFingerprint":"","IsRetry":false,"IsTimeout":false,"IsCancellation":false,"Message":"","SpanName":"","Route":"/oauth2/auth","Host":"users.k6n.net","StatusCode":401,"ExceptionType":"","ClientIP":"","UAClass":"","IsDuplicate":false,"IsRootCause":true,"HasUpstream":false,"TraceServices":["nginx"],"BadActor":""}}]},"patterns":[{"category":"dependency_unreachable","priority":"high","status":503,"host":"auth.k6n.net","path":"/","services":["nginx"],"routes":[{"ingress":"dev/zitadel-ingress","host":"auth.k6n.net","path":"/","pathType":"Prefix","namespace":"dev","service":"zitadel-dev","port":"8080","deployments":["zitadel-dev"]}],"pods":[{"namespace":"dev","deployment":"zitadel-dev","found":false}],"rule":"status-5xx-upstream","new":false,"count":4,"baseline":0.034482758620689655,"clients":1,"what":"HTTP 503 | /"},{"category":"client_credential_issue","priority":"low","status":401,"host":"users.k6n.net","path":"/oauth2/auth","services":["nginx"],"routes":[{"ingress":"ecommerce/ecommerce-auth","host":"users.k6n.net","path":"/","pathType":"Prefix","namespace":"ecommerce","service":"auth","port":"8080","deployments":["auth"]}],"pods":[{"namespace":"ecommerce","deployment":"auth","pod":"auth-8f497c689-qfpzb","lines":["2026/09/25 18:13:06 oidc: device grant JHMJ-C23E started for client dev-console","2026/09/25 18:13:06 oidc: device grant EQ5R-UWRW approved by mats@tornberg.me","2026/09/25 18:23:19 oidc: device grant RHMN-6257 started for client dev-console"],"found":true}],"rule":"status-401","new":false,"count":1,"baseline":0,"what":"HTTP 401 | /oauth2/auth"}]},{"id":"tlxn9d-nginx","at":"2026-09-25T18:27:13.347226389Z","spike":{"service":"nginx","windowStart":"2026-09-25T18:22:13.347226389Z","windowEnd":"2026-09-25T18:27:13.347226389Z","errorCount":64,"baselineMean":13.931034482758621,"excess":4,"patterns":[{"signature":"ce6a670c8bf24372ec95cd45548edf7c8e219f3b8dcdd37852800956555ac46f","count":4,"baselineMean":0,"excess":4,"novel":false,"clients":1,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:26:26.152736211Z","Service":"nginx","Release":"","Kind":"span","Source":"backend","IsError":true,"StatusClass":"","DurationMS":0,"TraceID":"d1de7de09ad342e7b0670cb78cac1430","PatternSignature":"ce6a670c8bf24372ec95cd45548edf7c8e219f3b8dcdd37852800956555ac46f","IssueFingerprint":"","IsRetry":false,"IsTimeout":false,"IsCancellation":false,"Message":"","SpanName":"","Route":"/","Host":"cc.k6n.net","StatusCode":405,"ExceptionType":"","ClientIP":"35.189.233.235","UAClass":"browser","IsDuplicate":false,"IsRootCause":true,"HasUpstream":false,"TraceServices":["nginx"],"BadActor":""}}]},"patterns":[{"category":"needs_review","priority":"low","status":405,"host":"cc.k6n.net","path":"/","services":["nginx"],"rule":"no-rule","new":false,"count":4,"baseline":0,"clients":1,"what":"HTTP 405 | /"}]},{"id":"tlxn61-nginx","at":"2026-09-25T18:25:13.346661147Z","spike":{"service":"nginx","windowStart":"2026-09-25T18:20:13.346661147Z","windowEnd":"2026-09-25T18:25:13.346661147Z","errorCount":60,"baselineMean":14.03448275862069,"excess":1,"patterns":[{"signature":"ea444cb83ff471aad7f3fab8a5b36dddb25f333f7dce2eb4ff27f6e9bb3969f7","count":1,"baselineMean":0,"excess":1,"novel":true,"clients":1,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:24:36.217473837Z","Service":"nginx","Release":"","Kind":"span","SourcLine truncated
|
||||||
|
|
||||||
@@ -5,10 +5,6 @@ RELEASE="${RELEASE:-$(git rev-parse --short HEAD)}"
|
|||||||
PUBLIC_SOURCEMAPS="${PUBLIC_SOURCEMAPS:-false}"
|
PUBLIC_SOURCEMAPS="${PUBLIC_SOURCEMAPS:-false}"
|
||||||
UPLOAD_SOURCEMAPS="${UPLOAD_SOURCEMAPS:-true}"
|
UPLOAD_SOURCEMAPS="${UPLOAD_SOURCEMAPS:-true}"
|
||||||
|
|
||||||
echo "Building Needle 3 sidecar image..."
|
|
||||||
docker build -t matst80/needle-sidecar:latest cmd/needle-sidecar
|
|
||||||
docker push matst80/needle-sidecar:latest
|
|
||||||
|
|
||||||
echo "Building otel-debug receiver image..."
|
echo "Building otel-debug receiver image..."
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg "APP_RELEASE=${RELEASE}" \
|
--build-arg "APP_RELEASE=${RELEASE}" \
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// laya-eval runs alert scenarios through laya and compares its shadow verdict
|
||||||
|
// with what a person judged the right response to be, so the model can be
|
||||||
|
// assessed before it is ever allowed to act.
|
||||||
|
//
|
||||||
|
// go run ./cmd/laya-eval # built-in scenarios
|
||||||
|
// go run ./cmd/laya-eval -file alerts.json # a GET /api/alerts dump (no expectations)
|
||||||
|
// go run ./cmd/laya-eval -send # also deliver each as a TEST alert
|
||||||
|
//
|
||||||
|
// -send uses the channels configured by OTEL_ALERT_* (see alerts.ConfigFromEnv).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-otel/internal/alerts"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed scenarios.json
|
||||||
|
var builtin []byte
|
||||||
|
|
||||||
|
// Scenario is one alert and the response a person would pick for it.
|
||||||
|
type Scenario struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Expect *Expectation `json:"expect,omitempty"`
|
||||||
|
Alert alerts.Alert `json:"alert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Expectation struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Human bool `json:"human"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
url := flag.String("url", envOr("OTEL_ALERT_LAYA_URL", "https://laya.k6n.net"), "laya base URL")
|
||||||
|
file := flag.String("file", "", "scenarios JSON, or a GET /api/alerts response (default: built-in scenarios)")
|
||||||
|
send := flag.Bool("send", false, "deliver each scenario, with its verdict, as a TEST alert to the configured channels")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
raw := builtin
|
||||||
|
if *file != "" {
|
||||||
|
var err error
|
||||||
|
if raw, err = os.ReadFile(*file); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scenarios, err := parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
var notifiers []alerts.Notifier
|
||||||
|
if *send {
|
||||||
|
cfg := alerts.ConfigFromEnv()
|
||||||
|
if notifiers = cfg.Notifiers(); len(notifiers) == 0 {
|
||||||
|
fail(fmt.Errorf("-send: no channel configured (OTEL_ALERT_TELEGRAM_TOKEN + OTEL_ALERT_TELEGRAM_CHAT_ID, OTEL_ALERT_WEBHOOK_URL or OTEL_ALERT_AOD_URL)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
laya := &alerts.Laya{URL: *url}
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "SCENARIO\tLAYA ACTION\tHUMAN\tURGENCY\tEXPECTED\tMATCH")
|
||||||
|
judged, actionOK, humanOK := 0, 0, 0
|
||||||
|
for _, s := range scenarios {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
v, err := laya.Classify(ctx, s.Alert)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(tw, "%s\terror: %v\t\t\t\t\n", s.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exp, match := "-", "-"
|
||||||
|
if s.Expect != nil {
|
||||||
|
judged++
|
||||||
|
a, h := v.Action == s.Expect.Action, (v.Human >= 0.5) == s.Expect.Human
|
||||||
|
if a {
|
||||||
|
actionOK++
|
||||||
|
}
|
||||||
|
if h {
|
||||||
|
humanOK++
|
||||||
|
}
|
||||||
|
exp = fmt.Sprintf("%s, human=%v", s.Expect.Action, s.Expect.Human)
|
||||||
|
match = fmt.Sprintf("action %s, human %s", mark(a), mark(h))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tw, "%s\t%s %.0f%%\t%.0f%%\t%s %.0f%%\t%s\t%s\n", s.Name, v.Action, v.ActionConfidence*100, v.Human*100, v.Urgency, v.UrgencyConf*100, exp, match)
|
||||||
|
|
||||||
|
if *send {
|
||||||
|
a := s.Alert
|
||||||
|
a.Test, a.Laya = true, v
|
||||||
|
for _, n := range notifiers {
|
||||||
|
sctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
if err := n.Send(sctx, a); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %s: %v\n", s.Name, n.Name(), err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tw.Flush()
|
||||||
|
if judged > 0 {
|
||||||
|
fmt.Printf("\nagreement over %d judged scenarios: action %d/%d, needs-human %d/%d\n", judged, actionOK, judged, humanOK, judged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse accepts a scenario list or a raw GET /api/alerts response.
|
||||||
|
func parse(raw []byte) ([]Scenario, error) {
|
||||||
|
var list []Scenario
|
||||||
|
if err := json.Unmarshal(raw, &list); err == nil {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
var st alerts.Status
|
||||||
|
if err := json.Unmarshal(raw, &st); err != nil {
|
||||||
|
return nil, fmt.Errorf("neither a scenario list nor an /api/alerts response: %w", err)
|
||||||
|
}
|
||||||
|
for _, a := range st.Alerts {
|
||||||
|
list = append(list, Scenario{Name: a.ID + " " + a.Spike.Service, Alert: a})
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mark(ok bool) string {
|
||||||
|
if ok {
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
return "MISS"
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,28 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
# Create non-root user with UID 1000 matching Kubernetes pod securityContext
|
|
||||||
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m -d /app appuser
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV HOME=/app
|
|
||||||
ENV NEEDLE_PORT=5005
|
|
||||||
ENV NEEDLE_HOST=0.0.0.0
|
|
||||||
ENV NEEDLE_TELEMETRY=0
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN pip install --no-cache-dir cactus-needle
|
|
||||||
|
|
||||||
# Pre-cache model and shared library into /app/.cache as user 1000
|
|
||||||
RUN python -c "import needle; needle.Needle()"
|
|
||||||
|
|
||||||
COPY server.py /app/server.py
|
|
||||||
|
|
||||||
# Ensure UID 1000 owns /app and all caches
|
|
||||||
RUN chown -R appuser:appuser /app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 5005
|
|
||||||
|
|
||||||
CMD ["python", "/app/server.py"]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
"""Deterministic baseline for the cause categories. Needle has to beat this."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
PROBE = re.compile(r"(^|/)(\.env|\.git|wp-|wp/|wp$|wordpress|blog/wp|phpmyadmin|cgi-bin|\.aws|\.ssh|xmlrpc|userfiles|@fs/|etc/passwd)", re.I)
|
|
||||||
DEP = re.compile(r"dial|connection refused|eof|no such host|upstream|unavailable|broken pipe|reset by peer", re.I)
|
|
||||||
CAP = re.compile(r"timeout|deadline exceeded|pool|memory limit|oom|too many|queue full|exhaust", re.I)
|
|
||||||
BUG = re.compile(r"panic|nil pointer|index out of range|typeerror|referenceerror|deadlock|unhandled|chunkloaderror|segfault", re.I)
|
|
||||||
LIFE = re.compile(r"healthz|readyz|/ping|retry|cancel|keepalive|vital|\bcls\b|ttfb|fcp|lcp|snapshot|replay|unchanged|session\.load", re.I)
|
|
||||||
|
|
||||||
|
|
||||||
def fields(text):
|
|
||||||
d = {}
|
|
||||||
for part in text.split(" | "):
|
|
||||||
k, _, v = part.partition(": ")
|
|
||||||
d[k.lower()] = v
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def classify(text):
|
|
||||||
f = fields(text)
|
|
||||||
status = int(f.get("status") or 0)
|
|
||||||
route = f.get("route", "")
|
|
||||||
blob = " ".join([f.get("title", ""), f.get("message", ""), route])
|
|
||||||
if PROBE.search(route):
|
|
||||||
return "scanner_probe"
|
|
||||||
if BUG.search(blob):
|
|
||||||
return "service_defect"
|
|
||||||
if CAP.search(blob):
|
|
||||||
return "capacity_saturation"
|
|
||||||
if DEP.search(blob):
|
|
||||||
return "dependency_unreachable"
|
|
||||||
if status == 401:
|
|
||||||
return "client_credential_issue"
|
|
||||||
if status in (502, 503, 504):
|
|
||||||
return "dependency_unreachable"
|
|
||||||
if status >= 500:
|
|
||||||
return "service_defect"
|
|
||||||
if status == 404:
|
|
||||||
return "stale_reference"
|
|
||||||
if LIFE.search(blob) or status in (200, 202, 204, 301, 302, 304, 308, 499) or (status == 0 and f.get("kind") in ("span", "log")):
|
|
||||||
return "expected_lifecycle"
|
|
||||||
return "needs_review"
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Evaluate the Needle sidecar against a reviewed golden set.
|
|
||||||
|
|
||||||
Usage: run_eval.py [--url http://127.0.0.1:5005] [--golden eval/golden.jsonl] [--json out.json]
|
|
||||||
|
|
||||||
Each golden row: {"id", "expected", "text"}; `expected` is a sidecar action name
|
|
||||||
including "needs_review" for the fallback.
|
|
||||||
"""
|
|
||||||
import argparse, json, os, sys, time, urllib.request
|
|
||||||
from collections import Counter, defaultdict
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
|
|
||||||
def classify(url, text):
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url.rstrip("/") + "/classify",
|
|
||||||
data=json.dumps({"text": text}).encode(),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=60) as r:
|
|
||||||
return json.load(r)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--url", default=os.environ.get("OTEL_NEEDLE_ADDR", "http://127.0.0.1:5005"))
|
|
||||||
ap.add_argument("--golden", default=os.path.join(HERE, "golden.jsonl"))
|
|
||||||
ap.add_argument("--json", help="write per-row results here")
|
|
||||||
ap.add_argument("--min-conf", type=float, default=0.0,
|
|
||||||
help="predictions below this confidence count as needs_review")
|
|
||||||
a = ap.parse_args()
|
|
||||||
|
|
||||||
rows = [json.loads(l) for l in open(a.golden) if l.strip()]
|
|
||||||
results, confusion = [], Counter()
|
|
||||||
lat = []
|
|
||||||
for row in rows:
|
|
||||||
try:
|
|
||||||
out = classify(a.url, row["text"])
|
|
||||||
except Exception as e:
|
|
||||||
print(f"sidecar error on {row['id']}: {e}", file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
pred = out.get("action", "needs_review")
|
|
||||||
conf = out.get("confidence", 0.0)
|
|
||||||
if conf < a.min_conf:
|
|
||||||
pred = "needs_review"
|
|
||||||
lat.append(out.get("latency_ms", 0.0))
|
|
||||||
confusion[(row["expected"], pred)] += 1
|
|
||||||
results.append({**row, "predicted": pred, "confidence": conf, "ok": pred == row["expected"]})
|
|
||||||
|
|
||||||
labels = sorted({r["expected"] for r in results} | {r["predicted"] for r in results})
|
|
||||||
print(f"{'label':<18}{'prec':>7}{'recall':>8}{'support':>9}")
|
|
||||||
for l in labels:
|
|
||||||
tp = confusion[(l, l)]
|
|
||||||
pp = sum(v for (e, p), v in confusion.items() if p == l)
|
|
||||||
sup = sum(v for (e, p), v in confusion.items() if e == l)
|
|
||||||
prec = tp / pp if pp else 0.0
|
|
||||||
rec = tp / sup if sup else 0.0
|
|
||||||
print(f"{l:<18}{prec:>7.2f}{rec:>8.2f}{sup:>9}")
|
|
||||||
|
|
||||||
acc = sum(r["ok"] for r in results) / len(results)
|
|
||||||
print(f"\naccuracy {acc:.2%} over {len(results)} rows; "
|
|
||||||
f"median latency {sorted(lat)[len(lat)//2]:.0f}ms")
|
|
||||||
|
|
||||||
for src in sorted({r.get("src", "?") for r in results}):
|
|
||||||
sub = [r for r in results if r.get("src", "?") == src]
|
|
||||||
print(f" {src:<11} {sum(r['ok'] for r in sub)}/{len(sub)}")
|
|
||||||
|
|
||||||
wrong = [r for r in results if not r["ok"]]
|
|
||||||
if wrong:
|
|
||||||
print("\nmisses:")
|
|
||||||
for r in wrong:
|
|
||||||
print(f" {r['id']:<8} expected={r['expected']:<16} got={r['predicted']:<16} conf={r['confidence']:.2f}")
|
|
||||||
if a.json:
|
|
||||||
json.dump(results, open(a.json, "w"), indent=1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Needle 3 AI Telemetry Triage Sidecar Service
|
|
||||||
Accepts OTel incident summaries and uses Cactus-Compute/needle3 to classify and extract actionable insights.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import hashlib
|
|
||||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
||||||
import needle
|
|
||||||
|
|
||||||
PORT = int(os.environ.get("NEEDLE_PORT", 5005))
|
|
||||||
HOST = os.environ.get("NEEDLE_HOST", "0.0.0.0")
|
|
||||||
|
|
||||||
# --- Cause-level triage tools ---
|
|
||||||
# These deliberately do not restate what the event already carries (status
|
|
||||||
# code, is_error, severity). Each one is a judgement about *why* it happened
|
|
||||||
# or *who should care*, which is what an alert reader needs.
|
|
||||||
@needle.tool
|
|
||||||
def report_scanner_probe(service: str, path: str, status_code: int):
|
|
||||||
"""Automated scan or exploit crawler requesting sensitive or nonexistent paths such as .env, wp-admin, .git, phpmyadmin. Applies whatever status the probe received, including 500 or 502."""
|
|
||||||
return {"action": "scanner_probe", "service": service, "path": path, "status_code": status_code}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def report_dependency_unreachable(service: str, dependency: str, error_message: str):
|
|
||||||
"""A downstream or upstream service is down, refusing connections, closed the stream (EOF), or returned upstream unavailable."""
|
|
||||||
return {"action": "dependency_unreachable", "service": service, "dependency": dependency, "error": error_message}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def report_client_credential_issue(service: str, route: str, reason: str):
|
|
||||||
"""A caller lacks a valid or unexpired token, for example an unauthenticated client polling an API or a registry pull without login. The caller's fault, not a fault in this service."""
|
|
||||||
return {"action": "client_credential_issue", "service": service, "route": route, "reason": reason}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def report_stale_reference(service: str, path: str):
|
|
||||||
"""Harmless missing resource caused by an old link, removed asset, deleted commit, or dev tooling request."""
|
|
||||||
return {"action": "stale_reference", "service": service, "path": path}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def report_service_defect(service: str, error_message: str):
|
|
||||||
"""A bug in the service's own code: panic, nil dereference, unhandled exception, index out of range, deadlock, or a 500 from a real application route."""
|
|
||||||
return {"action": "service_defect", "service": service, "error": error_message}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def report_capacity_saturation(service: str, resource: str, error_message: str):
|
|
||||||
"""Resource exhaustion or overload: timeouts, connection pool exhausted, memory limit exceeded, OOM, queue full."""
|
|
||||||
return {"action": "capacity_saturation", "service": service, "resource": resource, "error": error_message}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def report_expected_lifecycle(service: str, what: str):
|
|
||||||
"""Normal behaviour that is not a problem: health and readiness checks, keepalive pings, retries, cancellations, client disconnects, redirects, successful requests, routine info logs, web vitals."""
|
|
||||||
return {"action": "expected_lifecycle", "service": service, "what": what}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def report_needs_review(service: str, summary: str):
|
|
||||||
"""Fallback when none of the other categories clearly applies."""
|
|
||||||
return {"action": "needs_review", "service": service, "summary": summary}
|
|
||||||
|
|
||||||
# --- Redaction: nothing PII-bearing should reach the model or the cache ---
|
|
||||||
_REDACTIONS = [
|
|
||||||
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "<email>"),
|
|
||||||
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "<ip>"),
|
|
||||||
(re.compile(r"\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b"), "<uuid>"),
|
|
||||||
(re.compile(r"\beyJ[\w-]+\.[\w-]+\.[\w-]+\b"), "<jwt>"),
|
|
||||||
(re.compile(r"\b[0-9a-fA-F]{16,}\b"), "<hex>"),
|
|
||||||
(re.compile(r"([?&][\w.-]+=)[^&\s|]+"), r"\1<v>"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def redact(text: str) -> str:
|
|
||||||
for rx, sub in _REDACTIONS:
|
|
||||||
text = rx.sub(sub, text)
|
|
||||||
return text[:1000]
|
|
||||||
|
|
||||||
# Cache keyed on the redacted text: repeated signatures cost nothing.
|
|
||||||
CACHE_MAX = int(os.environ.get("NEEDLE_CACHE_MAX", 2000))
|
|
||||||
_cache = {}
|
|
||||||
_stats = {"hits": 0, "misses": 0}
|
|
||||||
|
|
||||||
# Initialize Needle Agent once
|
|
||||||
print(f"Loading Needle 3 model (Cactus-Compute/needle3)...")
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
agent = needle.Needle(tools=[
|
|
||||||
report_scanner_probe,
|
|
||||||
report_dependency_unreachable,
|
|
||||||
report_client_credential_issue,
|
|
||||||
report_stale_reference,
|
|
||||||
report_service_defect,
|
|
||||||
report_capacity_saturation,
|
|
||||||
report_expected_lifecycle,
|
|
||||||
report_needs_review,
|
|
||||||
])
|
|
||||||
print(f"Needle 3 loaded in {(time.perf_counter() - t0):.2f}s")
|
|
||||||
|
|
||||||
|
|
||||||
class NeedleHandler(BaseHTTPRequestHandler):
|
|
||||||
def _send_cors(self):
|
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Otel-Agent-Token")
|
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
|
||||||
self.send_response(204)
|
|
||||||
self._send_cors()
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == "/health" or self.path == "/api/needle/status":
|
|
||||||
self.send_response(200)
|
|
||||||
self._send_cors()
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
resp = {
|
|
||||||
"status": "ok",
|
|
||||||
"available": True,
|
|
||||||
"model": "Cactus-Compute/needle3",
|
|
||||||
"cache": {**_stats, "size": len(_cache)},
|
|
||||||
"tools": [
|
|
||||||
"scanner_probe", "dependency_unreachable", "client_credential_issue", "stale_reference",
|
|
||||||
"service_defect", "capacity_saturation", "expected_lifecycle", "needs_review"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
self.wfile.write(json.dumps(resp).encode("utf-8"))
|
|
||||||
else:
|
|
||||||
self.send_response(404)
|
|
||||||
self._send_cors()
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
if self.path in ("/classify", "/api/needle/classify"):
|
|
||||||
content_len = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_len)
|
|
||||||
try:
|
|
||||||
data = json.loads(body) if body else {}
|
|
||||||
except Exception as e:
|
|
||||||
self.send_response(400)
|
|
||||||
self._send_cors()
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
|
|
||||||
return
|
|
||||||
|
|
||||||
text = data.get("text", "").strip()
|
|
||||||
# If text is not provided, build from structured fields:
|
|
||||||
if not text:
|
|
||||||
parts = []
|
|
||||||
if data.get("service"):
|
|
||||||
parts.append(f"Service: {data['service']}")
|
|
||||||
if data.get("kind"):
|
|
||||||
parts.append(f"Kind: {data['kind']}")
|
|
||||||
if data.get("title"):
|
|
||||||
parts.append(f"Title: {data['title']}")
|
|
||||||
if data.get("message"):
|
|
||||||
parts.append(f"Message: {data['message']}")
|
|
||||||
if data.get("url"):
|
|
||||||
parts.append(f"URL: {data['url']}")
|
|
||||||
if data.get("status_code"):
|
|
||||||
parts.append(f"Status: {data['status_code']}")
|
|
||||||
text = " | ".join(parts)
|
|
||||||
|
|
||||||
if not text:
|
|
||||||
self.send_response(400)
|
|
||||||
self._send_cors()
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps({"error": "Missing 'text' or telemetry fields"}).encode("utf-8"))
|
|
||||||
return
|
|
||||||
|
|
||||||
text = redact(text)
|
|
||||||
key = hashlib.sha256(text.encode()).hexdigest()
|
|
||||||
cached = _cache.get(key)
|
|
||||||
if cached is not None:
|
|
||||||
_stats["hits"] += 1
|
|
||||||
out = {**cached, "cached": True}
|
|
||||||
self.send_response(200)
|
|
||||||
self._send_cors()
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(out).encode("utf-8"))
|
|
||||||
return
|
|
||||||
_stats["misses"] += 1
|
|
||||||
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
agent.reset()
|
|
||||||
res = agent.run(text, strict=False)
|
|
||||||
latency_ms = (time.perf_counter() - t_start) * 1000
|
|
||||||
|
|
||||||
results = res.get("results", [])
|
|
||||||
first_result = results[0] if results and isinstance(results[0], dict) else {}
|
|
||||||
action = first_result.get("action", "needs_review")
|
|
||||||
|
|
||||||
calls = res.get("function_calls", [])
|
|
||||||
tool_name = calls[0].get("name") if calls else "triage_tool"
|
|
||||||
|
|
||||||
out = {
|
|
||||||
"success": True,
|
|
||||||
"input_text": text,
|
|
||||||
"action": action,
|
|
||||||
"tool": tool_name,
|
|
||||||
"arguments": first_result,
|
|
||||||
"confidence": float(res.get("confidence", 0.0)),
|
|
||||||
"reasoning": res.get("reasoning", ""),
|
|
||||||
"latency_ms": round(latency_ms, 2),
|
|
||||||
"model": "Cactus-Compute/needle3"
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(_cache) >= CACHE_MAX:
|
|
||||||
_cache.pop(next(iter(_cache)))
|
|
||||||
_cache[key] = out
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self._send_cors()
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(out).encode("utf-8"))
|
|
||||||
else:
|
|
||||||
self.send_response(404)
|
|
||||||
self._send_cors()
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
# Concise logging
|
|
||||||
print(f"[needle-sidecar] {args[0]} {args[1]} -> {args[2]}")
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
|
||||||
server = ThreadingHTTPServer((HOST, PORT), NeedleHandler)
|
|
||||||
print(f"[needle-sidecar] Listening on http://{HOST}:{PORT}")
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nShutting down needle sidecar...")
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run()
|
|
||||||
@@ -490,8 +490,6 @@ func main() {
|
|||||||
uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap)
|
uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap)
|
||||||
uiMux.Handle("GET /api/sourcemaps", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleListSourceMaps)))
|
uiMux.Handle("GET /api/sourcemaps", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleListSourceMaps)))
|
||||||
uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks)))
|
uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks)))
|
||||||
uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus)
|
|
||||||
uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify)
|
|
||||||
|
|
||||||
// Protected API & WebSocket routes
|
// Protected API & WebSocket routes
|
||||||
uiMux.Handle("/api/history", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleHistory)))
|
uiMux.Handle("/api/history", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleHistory)))
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
// spike-alerts replays the deterministic spike rule over a bounded window of
|
// spike-alerts replays the deterministic spike rule over a bounded window of
|
||||||
// normalized_events and, optionally, asks the Needle sidecar what kind of
|
// normalized_events. It only reads ClickHouse.
|
||||||
// failure each top pattern is. It only reads ClickHouse.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,7 +33,6 @@ func main() {
|
|||||||
minExcess := flag.Int("min-excess", 5, "minimum excess errors to alert")
|
minExcess := flag.Int("min-excess", 5, "minimum excess errors to alert")
|
||||||
multiple := flag.Float64("multiple", 3, "pattern must be novel or >= multiple * its baseline")
|
multiple := flag.Float64("multiple", 3, "pattern must be novel or >= multiple * its baseline")
|
||||||
cooldown := flag.Duration("cooldown", 30*time.Minute, "suppress repeats of a pattern for this long unless its count doubles")
|
cooldown := flag.Duration("cooldown", 30*time.Minute, "suppress repeats of a pattern for this long unless its count doubles")
|
||||||
needle := flag.String("needle", "", "Needle sidecar URL (dormant: it is not accurate enough to gate alerts); empty skips it")
|
|
||||||
showMuted := flag.Bool("show-muted", false, "also report non-actionable categories (probes, stale references, expected lifecycle)")
|
showMuted := flag.Bool("show-muted", false, "also report non-actionable categories (probes, stale references, expected lifecycle)")
|
||||||
asJSON := flag.Bool("json", false, "emit JSON lines instead of text")
|
asJSON := flag.Bool("json", false, "emit JSON lines instead of text")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -75,7 +71,6 @@ func main() {
|
|||||||
log.Printf("bad-actor events (ignored for alerts, kept as data): %v of %d", badActors, len(events))
|
log.Printf("bad-actor events (ignored for alerts, kept as data): %v of %d", badActors, len(events))
|
||||||
log.Printf("loaded %d error events", len(events))
|
log.Printf("loaded %d error events", len(events))
|
||||||
|
|
||||||
cache := map[string]classification{}
|
|
||||||
first := start.Add(time.Duration(*baselineWindows) * *window)
|
first := start.Add(time.Duration(*baselineWindows) * *window)
|
||||||
suppress := features.NewSuppressor(*cooldown)
|
suppress := features.NewSuppressor(*cooldown)
|
||||||
muted := map[triage.Category]int{}
|
muted := map[triage.Category]int{}
|
||||||
@@ -87,24 +82,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
for _, spike := range suppress.Filter(detected) {
|
for _, spike := range suppress.Filter(detected) {
|
||||||
fired++
|
fired++
|
||||||
var cls []classification
|
|
||||||
for _, p := range spike.Patterns {
|
|
||||||
c := classification{}
|
|
||||||
if *needle != "" {
|
|
||||||
if hit, ok := cache[p.Signature]; ok {
|
|
||||||
c = hit
|
|
||||||
} else {
|
|
||||||
c = classify(*needle, needleText(p.Example))
|
|
||||||
cache[p.Signature] = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cls = append(cls, c)
|
|
||||||
}
|
|
||||||
if *asJSON {
|
if *asJSON {
|
||||||
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"spike": spike, "classifications": cls})
|
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"spike": spike})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
printSpike(spike, cls)
|
printSpike(spike)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c, n := range muted {
|
for c, n := range muted {
|
||||||
@@ -113,28 +95,7 @@ func main() {
|
|||||||
log.Printf("%d alert(s) between %s and %s", fired, first.Format(time.RFC3339), end.Format(time.RFC3339))
|
log.Printf("%d alert(s) between %s and %s", fired, first.Format(time.RFC3339), end.Format(time.RFC3339))
|
||||||
}
|
}
|
||||||
|
|
||||||
type classification struct {
|
func eventText(e features.Event) string {
|
||||||
Action string `json:"action"`
|
|
||||||
Confidence float64 `json:"confidence"`
|
|
||||||
Cached bool `json:"cached"`
|
|
||||||
Err string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func classify(base, text string) classification {
|
|
||||||
body, _ := json.Marshal(map[string]string{"text": text})
|
|
||||||
resp, err := (&http.Client{Timeout: 60 * time.Second}).Post(strings.TrimRight(base, "/")+"/classify", "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return classification{Action: "?", Err: err.Error()}
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var out classification
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
||||||
return classification{Action: "?", Err: err.Error()}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func needleText(e features.Event) string {
|
|
||||||
parts := []string{"Service: " + e.Service, "Kind: " + e.Kind}
|
parts := []string{"Service: " + e.Service, "Kind: " + e.Kind}
|
||||||
if e.SpanName != "" {
|
if e.SpanName != "" {
|
||||||
parts = append(parts, "Title: "+e.SpanName)
|
parts = append(parts, "Title: "+e.SpanName)
|
||||||
@@ -151,17 +112,14 @@ func needleText(e features.Event) string {
|
|||||||
return strings.Join(parts, " | ")
|
return strings.Join(parts, " | ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func printSpike(s features.Spike, cls []classification) {
|
func printSpike(s features.Spike) {
|
||||||
fmt.Printf("\n%s %s errors=%d (baseline %.1f/window, excess %.0f)\n", s.WindowEnd.Format("01-02 15:04"), s.Service, s.ErrorCount, s.BaselineMean, s.Excess)
|
fmt.Printf("\n%s %s errors=%d (baseline %.1f/window, excess %.0f)\n", s.WindowEnd.Format("01-02 15:04"), s.Service, s.ErrorCount, s.BaselineMean, s.Excess)
|
||||||
for i, p := range s.Patterns {
|
for _, p := range s.Patterns {
|
||||||
tag := "rising"
|
tag := "rising"
|
||||||
if p.Novel {
|
if p.Novel {
|
||||||
tag = "NEW"
|
tag = "NEW"
|
||||||
}
|
}
|
||||||
what := string(alerts.TriageOf(p.Example).Category)
|
what := string(alerts.TriageOf(p.Example).Category)
|
||||||
if cls[i].Action != "" {
|
|
||||||
what += fmt.Sprintf(" [needle:%s %.2f]", cls[i].Action, cls[i].Confidence)
|
|
||||||
}
|
|
||||||
clients := ""
|
clients := ""
|
||||||
if p.Clients > 0 {
|
if p.Clients > 0 {
|
||||||
clients = fmt.Sprintf(" [%d client%s]", p.Clients, map[bool]string{true: "", false: "s"}[p.Clients == 1])
|
clients = fmt.Sprintf(" [%d client%s]", p.Clients, map[bool]string{true: "", false: "s"}[p.Clients == 1])
|
||||||
@@ -169,7 +127,7 @@ func printSpike(s features.Spike, cls []classification) {
|
|||||||
if p.Example.UAClass != "" && uaclass.IsBot(p.Example.UAClass) {
|
if p.Example.UAClass != "" && uaclass.IsBot(p.Example.UAClass) {
|
||||||
clients += " [" + p.Example.UAClass + "]"
|
clients += " [" + p.Example.UAClass + "]"
|
||||||
}
|
}
|
||||||
fmt.Printf(" %-6s x%-4d base %-6.1f %-24s %s%s\n", tag, p.Count, p.BaselineMean, what, needleText(p.Example), clients)
|
fmt.Printf(" %-6s x%-4d base %-6.1f %-24s %s%s\n", tag, p.Count, p.BaselineMean, what, eventText(p.Example), clients)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,164 @@
|
|||||||
|
# EU browser RUM error telemetry: privacy findings
|
||||||
|
|
||||||
|
Research date: 2026-11-04. Legal information, not a legal opinion or a
|
||||||
|
deployment-specific compliance determination. SDK behaviour below is supplied
|
||||||
|
context, not an independently audited implementation. No SDK change is proposed
|
||||||
|
as automatically lawful.
|
||||||
|
|
||||||
|
## Short answer
|
||||||
|
|
||||||
|
**Not just cookies. Actual errors can sometimes be sent without consent, but
|
||||||
|
only after two separate gates are satisfied.** Replacing every error with
|
||||||
|
`Anonymous frontend event` is not a universal EU requirement. Conversely,
|
||||||
|
cookie-free, identifier-free or nominally anonymous browser telemetry is not
|
||||||
|
automatically consent-exempt. Legitimate interests cannot override an ePrivacy
|
||||||
|
consent requirement. [1-4]
|
||||||
|
|
||||||
|
## Gate 1: ePrivacy terminal storage/access
|
||||||
|
|
||||||
|
Article 5(3) concerns storing information on, or accessing information already
|
||||||
|
stored in, terminal equipment, **whether or not it is personal data**. The final
|
||||||
|
EDPB Guidelines 2/2023 cover JavaScript-directed transmissions, locally generated
|
||||||
|
information and RAM; there is no minimum storage duration. Thus no cookies,
|
||||||
|
localStorage or sessionStorage is helpful but not decisive. An ephemeral
|
||||||
|
in-memory page/session identifier can also matter. [1,3: paragraphs 4,6-12,32-39,52-56]
|
||||||
|
|
||||||
|
Consent is required unless the operation falls outside that scope or an
|
||||||
|
exception applies: storage/access solely to transmit a communication, or strictly
|
||||||
|
necessary to provide an information-society service explicitly requested by the
|
||||||
|
user. Assess the actual collection operation and purpose under applicable
|
||||||
|
national transposition and regulator guidance. Not every JavaScript API is
|
||||||
|
forbidden, and an ordinary HTTP request does not inherently require consent.
|
||||||
|
The EDPB's IP-tracking discussion distinguishes tracking from the technical use
|
||||||
|
of IP addresses to establish/transmit communications. [1,3: paragraph 4,
|
||||||
|
footnote 4, paragraphs 40,54-56 and footnote 28]
|
||||||
|
|
||||||
|
**Security/reliability is not a blanket exemption.** A narrowly necessary
|
||||||
|
function protecting or delivering the requested service may qualify; general
|
||||||
|
crash analytics, product improvement or performance measurement does not qualify
|
||||||
|
merely because it helps the operator. WP29 gives user-centric login-abuse
|
||||||
|
protection as a security example, but rejects a general first-party analytics
|
||||||
|
exemption. Its examples are not approval of a RUM SDK. Country-specific analytics
|
||||||
|
exemptions are not EU-wide permission: CNIL explicitly notes national variation.
|
||||||
|
[4: sections 3.3,4.3; 8]
|
||||||
|
|
||||||
|
## Gate 2: GDPR personal-data processing
|
||||||
|
|
||||||
|
If the telemetry or its collection involves personal data, an Article 6 legal
|
||||||
|
basis and the other GDPR requirements apply independently. IP addresses, device
|
||||||
|
metadata, stack URLs and error messages often identify or relate to people,
|
||||||
|
alone or in combination. Pseudonymous is not anonymous; hashing does not by
|
||||||
|
itself anonymise data. Assess reasonably available identification/linkage means,
|
||||||
|
including collector, proxy and vendor logs, not just the JSON payload.
|
||||||
|
[2: Article 4, Recitals 26,30; 7]
|
||||||
|
|
||||||
|
Article 6(1)(f) legitimate interests **may** support narrowly necessary service
|
||||||
|
reliability/security diagnostics without GDPR consent. Document a
|
||||||
|
legitimate-interest assessment before collection: a specific lawful purpose,
|
||||||
|
necessity (including less intrusive alternatives), and balancing against people's
|
||||||
|
rights, reasonable expectations and risks. This is a possible application of
|
||||||
|
the test, not an official finding that error telemetry always passes. Security
|
||||||
|
is recognised in Recital 49; it does not make all reliability analytics necessary.
|
||||||
|
Public authorities cannot rely on Article 6(1)(f) when performing their tasks.
|
||||||
|
[2: Article 6(1)(f), Recitals 47,49; 6]
|
||||||
|
|
||||||
|
Provide Article 13 information, including purposes, basis, legitimate interests,
|
||||||
|
recipients, retention period/criteria and rights. Apply Article 5 purpose and
|
||||||
|
data minimisation, storage limitation and accountability. Under Article 21,
|
||||||
|
handle objections based on the person's particular situation: cease the relevant
|
||||||
|
processing unless the applicable overriding-ground/legal-claims exception is
|
||||||
|
demonstrated. Special-category data needs an additional Article 9 condition;
|
||||||
|
legitimate interests alone is insufficient. [2: Articles 5,9,13,21; 6,7,9]
|
||||||
|
|
||||||
|
## If consent is denied or withdrawn
|
||||||
|
|
||||||
|
Stop/do not start optional consent-dependent browser collection; do not relabel
|
||||||
|
the **same consent-required processing** as legitimate interests after rejection.
|
||||||
|
The EDPB requires selecting the basis in advance and rejects retrospective
|
||||||
|
swapping from consent to legitimate interests. Independently established,
|
||||||
|
accurately disclosed necessary diagnostics are a different question: they may
|
||||||
|
continue only if both gates above are satisfied, not because refusal authorises
|
||||||
|
them. Keep them separate from optional analytics/tracking. [3-6]
|
||||||
|
|
||||||
|
Ordinary application error logs generated on the server while serving an actual
|
||||||
|
request do not, merely by being logged, add browser terminal storage/access.
|
||||||
|
GDPR can still apply. Browser RUM additionally instructs the terminal to collect
|
||||||
|
and transmit information, so a server-logging justification cannot automatically
|
||||||
|
be imported. Request headers, identifiers and any additional terminal access
|
||||||
|
still need their own assessment. [1-3: application of the scope test]
|
||||||
|
|
||||||
|
## Assessment of the initial SDK defaults
|
||||||
|
|
||||||
|
The described no-storage/no-cookie/no-beacon defaults, `fetch` with
|
||||||
|
`credentials: omit` and `no-referrer`, and removal of stack URLs/context reduce
|
||||||
|
exposure. Neither transport choice nor the label "anonymous" establishes
|
||||||
|
anonymity or exemption; the collector can still receive IP/network metadata.
|
||||||
|
Numeric performance measurements also require the same two-gate assessment.
|
||||||
|
Consent enabling raw diagnostics and an in-memory page ID does not remove
|
||||||
|
minimisation, security or sensitive-data obligations. [2,3,7]
|
||||||
|
|
||||||
|
**Conclusion:** blanket replacement of actual errors may be unnecessarily
|
||||||
|
restrictive for a separately justified minimal diagnostic purpose, while the
|
||||||
|
remaining performance collection may still need consent. Do not loosen the SDK
|
||||||
|
based only on "no cookies" or a generic security claim.
|
||||||
|
|
||||||
|
## Implemented policy controls
|
||||||
|
|
||||||
|
The SDK now separates count-only errors, an explicitly configured minimal
|
||||||
|
diagnostics sanitizer, consented diagnostics and host-allowlisted development
|
||||||
|
diagnostics. Unknown or rejected minimal events fall back to counts, not raw
|
||||||
|
exception content. The application is responsible for sanitizing permitted
|
||||||
|
output and independently justifying the policy before enabling it.
|
||||||
|
|
||||||
|
Development mode requires an explicit flag and an exact hostname allowlist;
|
||||||
|
the host application must first establish its own access/acceptance flow and
|
||||||
|
applicable legal basis. A development label, login, allowlist or SDK switch is
|
||||||
|
not a legal exemption. Withdrawal disables development mode and drops queued
|
||||||
|
data. Optional Web Vitals waits for permission unless independently enabled;
|
||||||
|
already-installed library observers require a reload for complete teardown.
|
||||||
|
|
||||||
|
These are engineering controls, not additional legal findings. Configuration,
|
||||||
|
limits and examples are documented in [the instrumentation guide](../instrumentation.md#cookieless-diagnostics-and-consent).
|
||||||
|
|
||||||
|
## Candidate minimal-diagnostics policy, subject to both gates
|
||||||
|
|
||||||
|
These are engineering recommendations, not a statutory safe harbour:
|
||||||
|
|
||||||
|
- Allow build/release, app-controlled route templates and an allowlisted safe
|
||||||
|
exception category. Prefer static classification over arbitrary exception text.
|
||||||
|
- Permit only demonstrably necessary, scrubbed messages/stack frames; exclude
|
||||||
|
query strings, URL fragments, user content, auth tokens/headers, request or
|
||||||
|
response bodies, emails and user-specific paths. Scrubbing is imperfect;
|
||||||
|
unknown free text should be omitted rather than assumed anonymous.
|
||||||
|
- No stable visitor ID or fingerprint; avoid even a page ID unless separately
|
||||||
|
necessary and assessed. Use a first-party collector without cross-site profiles
|
||||||
|
or vendor reuse. First-party hosting alone is not an exemption.
|
||||||
|
- Minimise IP/device metadata at collection, including ingress/access logs.
|
||||||
|
Apply short, justified TTLs, deletion and access controls. There is no universal
|
||||||
|
numeric EU retention period for RUM; document purpose-specific periods.
|
||||||
|
- Identify controller/processor roles, use required processor contracts
|
||||||
|
(Article 28), and review any third-country access/transfers (Chapter V).
|
||||||
|
Document the ePrivacy analysis, legitimate-interest assessment, notice and
|
||||||
|
objection handling before enabling the policy. [2: Articles 5,13,21,25,28,32,
|
||||||
|
Chapter V; 3-7,9]
|
||||||
|
|
||||||
|
## Primary official sources and verification limits
|
||||||
|
|
||||||
|
Only legislation and official regulator material were used. The EDPB/WP29 PDFs
|
||||||
|
and EDPB/CNIL pages below were retrieved and read. The requested EUR-Lex links
|
||||||
|
were checked, but returned no extractable text in this environment; their full
|
||||||
|
texts were **not independently retrieved**. Article 5(3)'s scope/exceptions were
|
||||||
|
cross-checked in the final EDPB guidance; GDPR findings were cross-checked against
|
||||||
|
the EDPB guidance below. Recheck current legislation and the relevant country's
|
||||||
|
law/guidance before an operational decision. Guidance interprets legislation;
|
||||||
|
this note does not establish a jurisdiction-specific exemption.
|
||||||
|
|
||||||
|
1. [ePrivacy Directive 2002/58/EC, consolidated 2009-12-19, Article 5(3)](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02002L0058-20091219).
|
||||||
|
2. [GDPR, Regulation (EU) 2016/679](https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng).
|
||||||
|
3. [EDPB Guidelines 2/2023, final page](https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22023-technical-scope-art-53-eprivacy-directive_en) and [version 2.0 PDF](https://www.edpb.europa.eu/system/files/documents/2024-10/edpb_guidelines_202302_technical_scope_art_53_eprivacydirective_v2_en_0.pdf), adopted 7 October 2024; page published 16 October 2024. Scope guidance, not an exemption catalogue.
|
||||||
|
4. [WP29 Opinion 04/2012 on cookie consent exemption](https://ec.europa.eu/justice/article-29/documentation/opinion-recommendation/files/2012/wp194_en.pdf), especially sections 3.3 and 4.3; historical guidance, subject to current national application.
|
||||||
|
5. [EDPB Guidelines 05/2020 on consent, final PDF](https://www.edpb.europa.eu/system/files/documents/files/file1/edpb_guidelines_202005_consent_en.pdf), paragraphs 120-123.
|
||||||
|
6. [EDPB: Process personal data lawfully](https://www.edpb.europa.eu/sme/be-compliant/process-personal-data-lawfully_en), legitimate interests and sensitive data.
|
||||||
|
7. [EDPB: Data protection basics](https://www.edpb.europa.eu/sme/learn-the-basics/data-protection-basics_en), identifiability and GDPR principles.
|
||||||
|
8. [CNIL: Sheet 16, analytics](https://www.cnil.fr/en/sheet-ndeg16-use-analytics-your-websites-and-applications), 11 June 2020; illustrative national guidance, not a current EU-wide exemption or RUM retention rule.
|
||||||
|
9. [EDPB: Respect individuals' rights](https://www.edpb.europa.eu/sme/be-compliant/respect-individuals-rights_en), information duties and objections.
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Error detection model
|
# Error detection model
|
||||||
|
|
||||||
> Latest status, decisions and next steps: [STATUS.md](STATUS.md). Where they
|
> Latest status, decisions and next steps: [STATUS.md](STATUS.md). Where they
|
||||||
> differ (ClickHouse is now a LoadBalancer; Needle is dormant), STATUS.md wins.
|
> differ (ClickHouse is now a LoadBalancer; Needle is removed), STATUS.md wins.
|
||||||
|
|
||||||
## Current handoff (2026-09-18)
|
## Current handoff (2026-09-18)
|
||||||
|
|
||||||
|
|||||||
+13
-22
@@ -10,7 +10,6 @@ unless stated. Greenfield, single user: radical changes are fine.
|
|||||||
patched live. `docs/ML.md` still says "keep ClusterIP"; that is superseded.
|
patched live. `docs/ML.md` still says "keep ClusterIP"; that is superseded.
|
||||||
- Credentials are in `.env` (gitignored, mode 600). Load with
|
- Credentials are in `.env` (gitignored, mode 600). Load with
|
||||||
`set -a; . ./.env; set +a`. `clickhouse-client` is installed locally.
|
`set -a; . ./.env; set +a`. `clickhouse-client` is installed locally.
|
||||||
- `.env` deliberately does not enable Needle; pass `-needle URL` explicitly.
|
|
||||||
|
|
||||||
## What exists now
|
## What exists now
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ unless stated. Greenfield, single user: radical changes are fine.
|
|||||||
| 404/410 are not errors | `isErrorStatus` in `internal/normalization/normalizer.go` | Done. Rows stored before this still count 404s as errors. |
|
| 404/410 are not errors | `isErrorStatus` in `internal/normalization/normalizer.go` | Done. Rows stored before this still count 404s as errors. |
|
||||||
| Backfill | `cmd/normalized-backfill` | Ran 2026-09-19 12:00 to ~21:00 UTC. Queries need `FINAL` (duplicates until merge). |
|
| Backfill | `cmd/normalized-backfill` | Ran 2026-09-19 12:00 to ~21:00 UTC. Queries need `FINAL` (duplicates until merge). |
|
||||||
| Spike rule | `internal/features/spike.go` | Excess-error rule, trimmed-mean baseline, `Suppressor` (cooldown, 2x escalation). Tested. |
|
| Spike rule | `internal/features/spike.go` | Excess-error rule, trimmed-mean baseline, `Suppressor` (cooldown, 2x escalation). Tested. |
|
||||||
| Replay CLI | `cmd/spike-alerts` | Replays a range; mutes non-actionable categories; Needle optional and off. |
|
| Replay CLI | `cmd/spike-alerts` | Replays a range; mutes non-actionable categories. |
|
||||||
| Cause categories | `internal/triage` | Deterministic rules, first match wins. Tested. |
|
| Cause categories | `internal/triage` | Deterministic rules, first match wins. Tested. |
|
||||||
| Rules scoring | `cmd/triage-eval` | Scores a labelled JSONL; also prepares rows for labelling. |
|
| Rules scoring | `cmd/triage-eval` | Scores a labelled JSONL; also prepares rows for labelling. |
|
||||||
| Offender report | `cmd/bad-actors` | Read-only. Ranks IPs from raw nginx spans, marks `[BLOCK]` / `[INTERNAL]`. |
|
| Offender report | `cmd/bad-actors` | Read-only. Ranks IPs from raw nginx spans, marks `[BLOCK]` / `[INTERNAL]`. |
|
||||||
@@ -35,14 +34,14 @@ Categories: `scanner_probe`, `dependency_unreachable`, `client_credential_issue`
|
|||||||
|
|
||||||
## Findings worth remembering
|
## Findings worth remembering
|
||||||
|
|
||||||
- **Needle scored 10% on the 40-row golden set** (about chance for 8 classes).
|
- **Needle was removed (2026-11-01).** It scored 10% on the 40-row golden set (about
|
||||||
Reversing the tool order changed its answers wholesale (15%, 34/40 `service_defect`),
|
chance for 8 classes), picked by tool position rather than content, and its
|
||||||
so it picks by position, not content. Confidence is uncorrelated with
|
confidence was uncorrelated with correctness. Sidecar, proxy handlers, UI and k8s
|
||||||
correctness. Decision: **Needle stays dormant.** Intended later use: a gate that
|
container are gone; the labelled sets live in `cmd/triage-eval/data/`.
|
||||||
decides whether a larger model should act on an issue (affects anyone? fixable by
|
- **Edge 4xx never pages.** nginx 4xx (registry 401 challenges, `/oauth2/auth`,
|
||||||
a smart model?). Do not use it for categorization.
|
scanner 405s) is dropped in `FilterActionable`; nginx 5xx still alerts.
|
||||||
- **Rules scored 39/40 on the golden set, but that is a fit** (written after seeing
|
- **Rules scored 39/40 on the golden set, but that is a fit** (written after seeing
|
||||||
the rows). A fresh 60-row holdout is in `cmd/needle-sidecar/eval/holdout.jsonl`
|
the rows). A fresh 60-row holdout is in `cmd/triage-eval/data/holdout.jsonl`
|
||||||
(from 2026-09-18, unseen); it still needs your `expected` labels. Known
|
(from 2026-09-18, unseen); it still needs your `expected` labels. Known
|
||||||
probe-like paths that still land in `stale_reference`: `/ssl/server.key`,
|
probe-like paths that still land in `stale_reference`: `/ssl/server.key`,
|
||||||
`/_debugbar/open`, `/debug/pprof`, `/actuator/*`, `/graphql`, `/server-info`,
|
`/_debugbar/open`, `/debug/pprof`, `/actuator/*`, `/graphql`, `/server-info`,
|
||||||
@@ -78,7 +77,7 @@ Categories: `scanner_probe`, `dependency_unreachable`, `client_credential_issue`
|
|||||||
1. Deploy the collector so normalized rows stay current; verify with
|
1. Deploy the collector so normalized rows stay current; verify with
|
||||||
`SELECT max(timestamp) FROM otel.normalized_events`.
|
`SELECT max(timestamp) FROM otel.normalized_events`.
|
||||||
2. Label `holdout.jsonl` (fill `expected`), run
|
2. Label `holdout.jsonl` (fill `expected`), run
|
||||||
`go run ./cmd/triage-eval -input cmd/needle-sidecar/eval/holdout.jsonl`, fix
|
`go run ./cmd/triage-eval -input cmd/triage-eval/data/holdout.jsonl`, fix
|
||||||
the rule families that miss. Then re-export a fresh holdout so scoring stays
|
the rule families that miss. Then re-export a fresh holdout so scoring stays
|
||||||
honest.
|
honest.
|
||||||
3. Warm-up guard in `DetectSpikes`: require baseline data before alerting (the
|
3. Warm-up guard in `DetectSpikes`: require baseline data before alerting (the
|
||||||
@@ -164,7 +163,7 @@ Categories: `scanner_probe`, `dependency_unreachable`, `client_credential_issue`
|
|||||||
```bash
|
```bash
|
||||||
set -a; . ./.env; set +a
|
set -a; . ./.env; set +a
|
||||||
|
|
||||||
# replay the spike rule over a range (Needle off)
|
# replay the spike rule over a range
|
||||||
go run ./cmd/spike-alerts -since 2026-09-19T12:00:00Z -until 2026-09-19T20:30:00Z -baseline-windows 12
|
go run ./cmd/spike-alerts -since 2026-09-19T12:00:00Z -until 2026-09-19T20:30:00Z -baseline-windows 12
|
||||||
# add -show-muted to see probes / stale / lifecycle patterns too
|
# add -show-muted to see probes / stale / lifecycle patterns too
|
||||||
|
|
||||||
@@ -172,12 +171,7 @@ go run ./cmd/spike-alerts -since 2026-09-19T12:00:00Z -until 2026-09-19T20:30:00
|
|||||||
go run ./cmd/bad-actors -since 2026-09-19T08:00:00Z -until 2026-09-19T20:30:00Z
|
go run ./cmd/bad-actors -since 2026-09-19T08:00:00Z -until 2026-09-19T20:30:00Z
|
||||||
|
|
||||||
# score the rules against labelled rows
|
# score the rules against labelled rows
|
||||||
go run ./cmd/triage-eval -input cmd/needle-sidecar/eval/holdout.jsonl
|
go run ./cmd/triage-eval -input cmd/triage-eval/data/holdout.jsonl
|
||||||
|
|
||||||
# Needle eval (needs the sidecar running; venv was in scratch and is gone)
|
|
||||||
pip install cactus-needle # python 3.12 recommended
|
|
||||||
NEEDLE_PORT=5005 NEEDLE_HOST=127.0.0.1 python cmd/needle-sidecar/server.py
|
|
||||||
python cmd/needle-sidecar/eval/run_eval.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Backfill in small windows only (10 to 30 minutes over the LAN); one-hour windows
|
Backfill in small windows only (10 to 30 minutes over the LAN); one-hour windows
|
||||||
@@ -187,14 +181,11 @@ through a `kubectl port-forward` hung and dropped.
|
|||||||
|
|
||||||
Everything is uncommitted. Files changed by this work: `internal/{storage,
|
Everything is uncommitted. Files changed by this work: `internal/{storage,
|
||||||
normalization,features,triage,honeypot,blocker}`, `cmd/{spike-alerts,
|
normalization,features,triage,honeypot,blocker}`, `cmd/{spike-alerts,
|
||||||
triage-eval,bad-actors,normalized-backfill,server,needle-sidecar}`,
|
triage-eval,bad-actors,normalized-backfill,server}`,
|
||||||
`k8s/{clickhouse,deployment}.yaml`, `.gitignore`.
|
`k8s/{clickhouse,deployment}.yaml`, `.gitignore`.
|
||||||
|
|
||||||
**Not from this work; check before committing:** `AGENTS.md`,
|
**Not from this work; check before committing:** `AGENTS.md`,
|
||||||
`internal/collector/{collector_test,issue_extract}.go`, `otel-ui/src/**`
|
`internal/collector/{collector_test,issue_extract}.go`, `otel-ui/src/**` (including a deleted `GaugeMeter.tsx`).
|
||||||
(including a deleted `GaugeMeter.tsx`). Also tracked `__pycache__/*.pyc` files
|
|
||||||
under `cmd/needle-sidecar` show as modified; they should be untracked and
|
|
||||||
gitignored.
|
|
||||||
|
|
||||||
## IP details (MaxMind)
|
## IP details (MaxMind)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Deploying go-otel with k8s-helper
|
||||||
|
|
||||||
|
Gitea Actions builds and pushes versioned and `latest` `matst80/otel-debug`
|
||||||
|
images on pushes to `main`, uploads the matching source maps, and then sends a
|
||||||
|
signed reconcile webhook. The agent applies the explicit `k8s/kustomization.yaml`
|
||||||
|
resource list into namespace `monitoring`.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
The k8s-helper GitOps CRDs and agent must be installed. The GitSource reuses
|
||||||
|
`dev/gitea-k8s-helper`, whose token must have read access to this repository.
|
||||||
|
Configure a Gitea push-only webhook for this repo at
|
||||||
|
`https://ui.k6n.net/api/gitops/webhooks/gitea`, using the same secret as
|
||||||
|
`K8SHELPER_GITOPS_WEBHOOK_SECRET` on the dashboard. Add that value as the Gitea
|
||||||
|
Actions secret `GITOPS_WEBHOOK_SECRET` too; a user- or organization-level
|
||||||
|
secret can be shared by other repos.
|
||||||
|
|
||||||
|
The runner uses its existing Docker login. Bootstrap once with
|
||||||
|
`kubectl apply -f gitops/` after confirming the monitoring namespace and the
|
||||||
|
`otel-sourcemaps` PVC already exist. The PVC is deliberately excluded from the
|
||||||
|
managed kustomization because its lifecycle was called out for separate review.
|
||||||
|
The ClickHouse manifests, auth-dev resources, and ingress-catchall work are also
|
||||||
|
excluded; this kustomization lists only the receiver Deployment, Service, and
|
||||||
|
Ingress resources. Do not run Flux and k8s-helper against the same resources at
|
||||||
|
the same time; check for an active `flux-system/go-otel` Kustomization before
|
||||||
|
bootstrapping this controller.
|
||||||
|
|
||||||
|
Prune is initially disabled. Inspect the GitOps inventory and applied revision
|
||||||
|
before changing it.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: gitops.k6n.net/v1alpha1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: go-otel
|
||||||
|
namespace: dev
|
||||||
|
spec:
|
||||||
|
sourceRef:
|
||||||
|
kind: GitSource
|
||||||
|
name: go-otel
|
||||||
|
path: ./k8s
|
||||||
|
targetNamespace: monitoring
|
||||||
|
prune: false
|
||||||
|
wait: true
|
||||||
|
deletionPolicy: Orphan
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Keep GitOps CRs outside the path they manage.
|
||||||
|
apiVersion: gitops.k6n.net/v1alpha1
|
||||||
|
kind: GitSource
|
||||||
|
metadata:
|
||||||
|
name: go-otel
|
||||||
|
namespace: dev
|
||||||
|
spec:
|
||||||
|
url: https://git.k6n.net/mats/go-otel.git
|
||||||
|
ref:
|
||||||
|
branch: main
|
||||||
|
interval: 5m
|
||||||
|
secretRef:
|
||||||
|
name: gitea-k8s-helper
|
||||||
|
ignore: |
|
||||||
|
.gitea/
|
||||||
|
docs/
|
||||||
-36
@@ -141,45 +141,9 @@
|
|||||||
Clear Logs
|
Clear Logs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="data-section" style="border-left: 4px solid #10b981;">
|
|
||||||
<h2 style="border-bottom-color: #10b981; color: #065f46;">Needle 3 AI Telemetry Triage</h2>
|
|
||||||
<p style="font-size: 13px; color: #666; margin-top: 0;">Test on-device telemetry classification with Cactus-Compute/needle3:</p>
|
|
||||||
<div style="display: flex; gap: 8px; margin-bottom: 10px;">
|
|
||||||
<input id="needle-input" type="text" style="flex: 1; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;" value="User reported client browser crash on service eventsourced-ai-web: Script error on url https://event-agent.k6n.net/#session=angry-koala-1" />
|
|
||||||
<button id="needle-btn" style="background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 4px; font-weight: bold; cursor: pointer;" onclick="classifyWithNeedle()">
|
|
||||||
Classify with Needle 3
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="needle-result" class="data-content" style="background: #ecfdf5; border: 1px solid #a7f3d0; min-height: 40px; color: #064e3b;">
|
|
||||||
Click "Classify with Needle 3" to test the sidecar...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function classifyWithNeedle() {
|
|
||||||
const input = document.getElementById("needle-input").value;
|
|
||||||
const resultDiv = document.getElementById("needle-result");
|
|
||||||
const btn = document.getElementById("needle-btn");
|
|
||||||
if (!input.trim()) return;
|
|
||||||
btn.textContent = "Classifying...";
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/needle/classify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ text: input }),
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
resultDiv.innerHTML = `<pre style="margin: 0; font-family: monospace;">${JSON.stringify(data, null, 2)}</pre>`;
|
|
||||||
} catch (err) {
|
|
||||||
resultDiv.textContent = "Error: " + err;
|
|
||||||
} finally {
|
|
||||||
btn.textContent = "Classify with Needle 3";
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ws;
|
let ws;
|
||||||
let tracesCount = 0;
|
let tracesCount = 0;
|
||||||
let metricsCount = 0;
|
let metricsCount = 0;
|
||||||
|
|||||||
+205
-6
@@ -31,6 +31,21 @@ Load the RUM script and initialize it once, before the application starts:
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To avoid parser-blocking downloads, use a deferred script with declarative
|
||||||
|
initialization instead of the two scripts above:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script defer src="https://otel.example.com/static/otel-rum.js"
|
||||||
|
data-service-name="checkout-web"
|
||||||
|
data-release="2026.09.18-a1b2c3"
|
||||||
|
data-environment="production"
|
||||||
|
data-base-url="https://otel.example.com"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Declarative initialization runs at `DOMContentLoaded`. Use the earlier,
|
||||||
|
explicit initialization when capturing errors and requests during application
|
||||||
|
startup matters more than avoiding parser blocking. Do not initialize twice.
|
||||||
|
|
||||||
The collector captures uncaught errors, unhandled promise rejections, failed
|
The collector captures uncaught errors, unhandled promise rejections, failed
|
||||||
HTTP requests, breadcrumbs, Web Vitals, and optional trace context. Correlate
|
HTTP requests, breadcrumbs, Web Vitals, and optional trace context. Correlate
|
||||||
frontend requests with backend spans when possible:
|
frontend requests with backend spans when possible:
|
||||||
@@ -52,6 +67,170 @@ try {
|
|||||||
Do not put secrets, tokens, or sensitive customer data in breadcrumbs or
|
Do not put secrets, tokens, or sensitive customer data in breadcrumbs or
|
||||||
custom attributes.
|
custom attributes.
|
||||||
|
|
||||||
|
### Cookieless diagnostics and consent
|
||||||
|
|
||||||
|
Neither RUM nor Todo reads or writes cookies, sessionStorage, or localStorage.
|
||||||
|
Configuration and consent are held only in memory. Integrate with the
|
||||||
|
application's existing consent manager rather than adding a tracking cookie
|
||||||
|
or another banner:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const rum = OtelRUM.init({ serviceName: "checkout-web" });
|
||||||
|
rum.setConsent("granted");
|
||||||
|
rum.setConsent("denied");
|
||||||
|
```
|
||||||
|
|
||||||
|
For a previously accepted choice, supply `consent: "granted"` at initialization
|
||||||
|
or `data-consent="granted"` on the script. Omitted consent means `"denied"`;
|
||||||
|
explicit configuration overrides the attribute. `rum.getConsent()` returns
|
||||||
|
the current choice. The application is responsible for persisting that choice
|
||||||
|
and applying it on the next page load.
|
||||||
|
|
||||||
|
The SDK separates error diagnostics from optional performance collection:
|
||||||
|
|
||||||
|
| Policy | Error payload | Performance collection |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Default `counts` | Generic event, severity, recognised error type, HTTP status, release/environment | Off without consent |
|
||||||
|
| Configured `minimal` | Counts plus application-sanitized diagnostic fields | Still off without consent |
|
||||||
|
| `consented` | Detailed errors, stacks, context, breadcrumbs and page metadata | On unless disabled |
|
||||||
|
| Explicit, host-allowlisted `development` | Detailed diagnostics for an already-approved development site | On unless disabled |
|
||||||
|
|
||||||
|
`rum.getPrivacyMode()` reports the configured active policy. An individual
|
||||||
|
minimal event falls back to counts if its sanitizer does not approve it.
|
||||||
|
Counts/minimal payloads exclude automatic URLs, user agents, viewport/connection
|
||||||
|
details, session ids, DOM selectors, breadcrumbs, context and custom attributes.
|
||||||
|
Per-request trace-header instrumentation still runs without persistent visitor
|
||||||
|
identity, but counts/minimal log records do not include trace ids. Neither
|
||||||
|
policy is a guarantee of anonymity or a consent exemption.
|
||||||
|
|
||||||
|
### Minimal actual-error diagnostics
|
||||||
|
|
||||||
|
Only configure this policy after separately assessing the diagnostic purpose,
|
||||||
|
ePrivacy position and GDPR legal basis; it is not automatically enabled when
|
||||||
|
tracking consent is rejected. Provide an application-owned sanitizer that maps
|
||||||
|
known errors to useful, reviewed diagnostics:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const safeDiagnostics = new Map([
|
||||||
|
["checkout.price-missing", {
|
||||||
|
message: "Checkout price is missing",
|
||||||
|
code: "CHECKOUT_PRICE_MISSING"
|
||||||
|
}]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rum = OtelRUM.init({
|
||||||
|
serviceName: "checkout-web",
|
||||||
|
minimalDiagnostics: {
|
||||||
|
route: "/checkout/:step",
|
||||||
|
sanitize: event => safeDiagnostics.get(event.message) || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rum.captureMessage("checkout.price-missing");
|
||||||
|
```
|
||||||
|
|
||||||
|
The sanitizer receives bounded `message`, optional `type` and `stacktrace`.
|
||||||
|
It may return `{message, code?, stacktrace?}`; only those fields and the
|
||||||
|
configured route template are added. Return `null` for unknown/free-text
|
||||||
|
events. Missing, throwing or invalid sanitizers fall back to counts without
|
||||||
|
sending the original message or stack. Output lengths are bounded, but **the
|
||||||
|
SDK trusts sanitizer output: it does not automatically scrub returned text**.
|
||||||
|
Only return reviewed messages and necessary scrubbed stack frames; never
|
||||||
|
forward `event` unchanged. Exclude credentials, query strings, fragments,
|
||||||
|
user-specific paths, user content and sensitive data. The route must be an
|
||||||
|
application-controlled template, not `location.pathname`.
|
||||||
|
|
||||||
|
On withdrawal, detailed queued records are dropped and an independently
|
||||||
|
configured minimal policy continues. Without that policy, counts continue.
|
||||||
|
Do not use this to relabel consent-required processing after refusal. Even
|
||||||
|
counts require a deployment-specific assessment. See `docs/EU-RUM-PRIVACY.md`.
|
||||||
|
|
||||||
|
### Development sites with existing access and acceptance
|
||||||
|
|
||||||
|
Development mode is explicit and requires an **exact hostname allowlist**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const rum = OtelRUM.init({
|
||||||
|
serviceName: "checkout-web",
|
||||||
|
environment: "development",
|
||||||
|
devMode: accessState.loggedIn && accessState.diagnosticsAccepted,
|
||||||
|
devModeHosts: ["localhost", "checkout.dev.example.com"]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Here `accessState` is provided by the host application's existing login and
|
||||||
|
acceptance flow. Do not initialize development mode before that flow completes.
|
||||||
|
Login alone does not establish consent or another legal basis. The SDK cannot
|
||||||
|
verify the login, notice, acceptance or purpose; the application must do so.
|
||||||
|
The allowlist is an accidental-production safeguard, not an authentication
|
||||||
|
boundary. Wildcards/subdomains do not match automatically; an environment
|
||||||
|
label never enables this mode. Ports are not part of the hostname match.
|
||||||
|
|
||||||
|
For declarative setup after the access gate, use `data-dev-mode="true"` and
|
||||||
|
`data-dev-mode-hosts="localhost,checkout.dev.example.com"`. Explicit
|
||||||
|
`devMode: false` overrides the attribute. Manual `captureMessage(message,
|
||||||
|
"INFO")`, exceptions, context and breadcrumbs are available for development
|
||||||
|
improvement logs. Do not include passwords, tokens or sensitive user content.
|
||||||
|
|
||||||
|
Development mode does not manufacture a consent grant: `getConsent()` still
|
||||||
|
returns the actual configured choice. Call `rum.setConsent("denied")` on logout
|
||||||
|
or withdrawal; it disables development mode even if consent was already denied,
|
||||||
|
discards queued data and returns to minimal/counts. Re-enabling requires a new
|
||||||
|
page initialization after the access gate. Todo remains consent-controlled
|
||||||
|
separately, and is still disabled by default.
|
||||||
|
|
||||||
|
### Performance permission and withdrawal
|
||||||
|
|
||||||
|
Web Vitals is not loaded until consent is granted or allowed development mode
|
||||||
|
is active. `captureWebVitals: false` disables it in all modes. If a separate
|
||||||
|
assessment establishes permission for cookieless performance collection, set
|
||||||
|
`captureWebVitalsWithoutConsent: true` or `data-vitals-without-consent="true"`.
|
||||||
|
Explicit `false` overrides the attribute; this option is not a legal exemption.
|
||||||
|
In that mode only values, deltas and numeric attribution are sent without
|
||||||
|
consent. A random, in-memory page-view id deduplicates snapshots within one
|
||||||
|
load; metric ids, selectors and other text attribution are omitted.
|
||||||
|
|
||||||
|
Detailed diagnostics use an in-memory-only session id, renewed on reload or
|
||||||
|
re-grant. Every consent change discards queued records, breadcrumbs, context
|
||||||
|
and pending vitals; data already sent cannot be recalled. Reapply identifying
|
||||||
|
context only after permission. Withdrawal suppresses later vitals callbacks
|
||||||
|
and reports unless cookieless performance was independently enabled. Observers
|
||||||
|
already installed inside the vendored Web Vitals library cannot be unloaded by
|
||||||
|
this SDK; reload the page after withdrawal if complete observer teardown is
|
||||||
|
required. If consent is revoked before its download completes, no metric
|
||||||
|
subscriptions are started.
|
||||||
|
|
||||||
|
Telemetry uses `fetch` with `credentials: "omit"`, `referrerPolicy: "no-referrer"`
|
||||||
|
and `keepalive: true` in every mode; no cookie-bearing `sendBeacon` fallback is used. If fetch
|
||||||
|
is unavailable or fails, telemetry is dropped. Browser-managed network headers
|
||||||
|
and client IP addresses remain visible to the receiving infrastructure, so
|
||||||
|
this is client-side data minimization, not a guarantee of legal anonymization
|
||||||
|
or a determination that consent is unnecessary. Review server logging,
|
||||||
|
retention, enrichment and applicable requirements separately.
|
||||||
|
|
||||||
|
### Building the browser scripts
|
||||||
|
|
||||||
|
The maintained TypeScript sources are `rum/src/otel-rum.ts` and
|
||||||
|
`rum/src/otel-todo.ts`; public options and APIs are defined in
|
||||||
|
`rum/src/types.ts`. Do not edit the generated `static/otel-rum.js` or
|
||||||
|
`static/otel-todo.js` directly.
|
||||||
|
|
||||||
|
Run `make build-rum` to install locked dependencies, typecheck with strict
|
||||||
|
TypeScript, and generate separate minified IIFE bundles at the existing
|
||||||
|
`/static/otel-rum.js` and `/static/otel-todo.js` URLs. Todo stays separate so
|
||||||
|
it is never downloaded unless enabled. Run `make test-rum` for the same build
|
||||||
|
plus regression tests against the generated JavaScript. After installing
|
||||||
|
dependencies, `npm --prefix rum test` also runs the build and tests.
|
||||||
|
|
||||||
|
`make build` builds the browser scripts and Go server. Docker builds and tests
|
||||||
|
the browser scripts in an isolated Node stage automatically, so `build.sh`
|
||||||
|
also uses fresh bundles. Generated JavaScript remains checked in for direct
|
||||||
|
`go run ./cmd/server` development; rebuild it whenever TypeScript changes.
|
||||||
|
|
||||||
|
External source maps are generated locally but are not downloaded by browsers
|
||||||
|
automatically. Docker omits them by default; `PUBLIC_SOURCEMAPS=true` retains
|
||||||
|
them, as for the UI. TypeScript and esbuild are build-time dependencies only.
|
||||||
|
|
||||||
## React error boundaries
|
## React error boundaries
|
||||||
|
|
||||||
Rendering errors are not reliably caught by global `window.onerror` handlers.
|
Rendering errors are not reliably caught by global `window.onerror` handlers.
|
||||||
@@ -93,13 +272,27 @@ for individual views and the application root.
|
|||||||
|
|
||||||
`otel-todo.js` adds a small floating symbol and a keyboard shortcut
|
`otel-todo.js` adds a small floating symbol and a keyboard shortcut
|
||||||
(default `Alt+Shift+T`) that opens a corner dialog for filing a todo/issue
|
(default `Alt+Shift+T`) that opens a corner dialog for filing a todo/issue
|
||||||
from the page, with an optional component picker. The current path and hash
|
from the page, with an optional component picker. User-submitted text, selected
|
||||||
are posted with it.
|
components and explicitly picked element references are sent as requested;
|
||||||
|
URLs, user agents and session metadata are attached only with consent. A note
|
||||||
|
can itself contain personal information, so it is not guaranteed anonymous.
|
||||||
|
|
||||||
`otel-rum.js` loads it automatically (from the same directory as the RUM
|
Todo is **disabled by default** in `otel-rum.js`. Opt in with
|
||||||
script) and reuses its service name, base URL and ingest key. Opt out with
|
`OtelRUM.init({ enableTodo: true })` or `data-enable-todo="true"` on the RUM
|
||||||
`OtelRUM.init({ excludeTodo: true })` or `data-exclude-todo="true"`. Or load it
|
script. It reuses the service name, base URL and ingest key, and loads from
|
||||||
standalone:
|
the same directory as the RUM script.
|
||||||
|
|
||||||
|
When enabled, both loading and initialization wait until after `window.load`,
|
||||||
|
then run during `requestIdleCallback` with no forced timeout. If idle callbacks
|
||||||
|
are unavailable, loading waits three seconds after `load`. On a continuously
|
||||||
|
busy page, idle loading may be delayed indefinitely. Error capture and request
|
||||||
|
instrumentation do not wait for Todo.
|
||||||
|
|
||||||
|
Explicit `enableTodo: false` overrides the opt-in attribute. The legacy
|
||||||
|
`excludeTodo: true` and `data-exclude-todo="true"` remain supported and veto
|
||||||
|
opt-in. Passing `excludeTodo: false` no longer enables Todo.
|
||||||
|
|
||||||
|
Alternatively, load it standalone (this bypasses RUM's idle scheduling):
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="https://otel.example.com/static/otel-todo.js"
|
<script src="https://otel.example.com/static/otel-todo.js"
|
||||||
@@ -111,6 +304,12 @@ standalone:
|
|||||||
Components are also discovered from `[data-component="..."]` elements. Other
|
Components are also discovered from `[data-component="..."]` elements. Other
|
||||||
options (on the RUM or todo script, or `OtelRUM.init({ todo: {...} })`): `data-shortcut`, `data-hide-button="true"`, `data-base-url`.
|
options (on the RUM or todo script, or `OtelRUM.init({ todo: {...} })`): `data-shortcut`, `data-hide-button="true"`, `data-base-url`.
|
||||||
|
|
||||||
|
When loaded by RUM, Todo reads the current RUM consent at submission time,
|
||||||
|
including after withdrawal. Standalone Todo defaults to denied consent; use
|
||||||
|
`OtelTodo.instance.setConsent("granted" | "denied")` from the consent manager,
|
||||||
|
or initialize with `consent` / `data-consent`. Submissions always omit browser
|
||||||
|
credentials and referrers, and no browser storage is used.
|
||||||
|
|
||||||
- `POST /v1/todos` (ingest key, CORS) body `{project, text, component, path, hash, url, ua, session}`
|
- `POST /v1/todos` (ingest key, CORS) body `{project, text, component, path, hash, url, ua, session}`
|
||||||
- `GET /api/todos?project=<service>&limit=100` (authenticated; omit `project` for all), newest first
|
- `GET /api/todos?project=<service>&limit=100` (authenticated; omit `project` for all), newest first
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ func TriageOf(e features.Event) triage.Result {
|
|||||||
IsTimeout: e.IsTimeout, IsCancellation: e.IsCancellation, IsRetry: e.IsRetry})
|
IsTimeout: e.IsTimeout, IsCancellation: e.IsCancellation, IsRetry: e.IsRetry})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// edgeClientError reports a 4xx seen at the nginx edge: registry auth
|
||||||
|
// challenges, /oauth2/auth subrequests, 405s from scanners. These are the
|
||||||
|
// caller's problem and never page, even next to a real 5xx.
|
||||||
|
func edgeClientError(e features.Event) bool {
|
||||||
|
return e.Service == "nginx" && e.StatusCode >= 400 && e.StatusCode < 500
|
||||||
|
}
|
||||||
|
|
||||||
// FilterActionable keeps only patterns whose category should page a human
|
// FilterActionable keeps only patterns whose category should page a human
|
||||||
// (unless keepMuted), recomputing each spike's excess so a probe storm cannot
|
// (unless keepMuted), recomputing each spike's excess so a probe storm cannot
|
||||||
// alert by itself. muted, when non-nil, counts what was dropped per category.
|
// alert by itself. muted, when non-nil, counts what was dropped per category.
|
||||||
@@ -21,7 +28,7 @@ func FilterActionable(spikes []features.Spike, keepMuted bool, muted map[triage.
|
|||||||
sp.Excess = 0
|
sp.Excess = 0
|
||||||
for _, p := range sp.Patterns {
|
for _, p := range sp.Patterns {
|
||||||
c := TriageOf(p.Example).Category
|
c := TriageOf(p.Example).Category
|
||||||
if !keepMuted && !c.Actionable() {
|
if !keepMuted && (!c.Actionable() || edgeClientError(p.Example)) {
|
||||||
if muted != nil {
|
if muted != nil {
|
||||||
muted[c]++
|
muted[c]++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Laya asks the laya model (POST {URL}/predict) what it would do about an
|
||||||
|
// alert. It runs in shadow mode: the verdict is recorded on the alert and
|
||||||
|
// shown in notifications, but nothing is ever acted on, muted or dropped
|
||||||
|
// because of it. A failed call leaves the alert exactly as it was.
|
||||||
|
type Laya struct {
|
||||||
|
URL string // base URL, e.g. https://laya.k6n.net
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// LayaActions are the remediations laya may choose between. They are
|
||||||
|
// suggestions only; none is wired to an automated action yet.
|
||||||
|
var LayaActions = map[string]string{
|
||||||
|
"ignore": "transient blip or noise, nobody needs to look at it",
|
||||||
|
"restart_deployment": "the service is stuck, leaking or crashed; restarting its pods is likely to clear it",
|
||||||
|
"check_capacity": "requests time out or resources are exhausted; check load, connection pools, CPU/memory limits and scaling",
|
||||||
|
"fix_dependency": "a database, upstream service or network path this service depends on is failing",
|
||||||
|
"investigate_code": "a bug or bad release in this service; needs a code fix or a rollback",
|
||||||
|
}
|
||||||
|
|
||||||
|
var layaUrgency = []string{"cosmetic", "degraded performance", "service outage"}
|
||||||
|
|
||||||
|
// LayaVerdict is laya's answer, reduced to what a person needs to judge it.
|
||||||
|
type LayaVerdict struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ActionConfidence float64 `json:"actionConfidence"` // probability of the chosen action
|
||||||
|
ActionProbs map[string]float64 `json:"actionProbs,omitempty"`
|
||||||
|
Human float64 `json:"human"` // probability a person must intervene
|
||||||
|
Urgency string `json:"urgency"`
|
||||||
|
UrgencyScore float64 `json:"urgencyScore"` // expected value on 0 (cosmetic) .. 2 (outage)
|
||||||
|
UrgencyConf float64 `json:"urgencyConfidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func layaRequest(state string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"state": state,
|
||||||
|
"questions": map[string]any{
|
||||||
|
"remediation_action": map[string]any{
|
||||||
|
"type": "choice",
|
||||||
|
"instructions": "Which operational response fits this production error alert best?",
|
||||||
|
"criteria": LayaActions,
|
||||||
|
},
|
||||||
|
"requires_human_attention": map[string]any{
|
||||||
|
"type": "noul",
|
||||||
|
"instructions": "Does this alert need a human engineer to intervene, or will it resolve or matter little on its own?",
|
||||||
|
"criteria": map[string]string{
|
||||||
|
"true": "errors are new or rising well above normal and users or requests are failing",
|
||||||
|
"false": "a transient blip, client noise or bot traffic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"urgency": map[string]any{
|
||||||
|
"type": "score",
|
||||||
|
"instructions": "Rate the operational severity of this alert.",
|
||||||
|
"criteria": layaUrgency,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type layaAnswer struct {
|
||||||
|
Choice string `json:"choice"`
|
||||||
|
Probabilities map[string]float64 `json:"probabilities"`
|
||||||
|
AnswerConfidence float64 `json:"answer_confidence"`
|
||||||
|
Noul float64 `json:"noul"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Legend map[string]string `json:"legend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type layaResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Answers map[string]layaAnswer `json:"answers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify asks laya about the alert. The state is the alert's plain-text
|
||||||
|
// message, so the model sees exactly the facts a person would, nothing more.
|
||||||
|
func (l *Laya) Classify(ctx context.Context, a Alert) (*LayaVerdict, error) {
|
||||||
|
a.Laya = nil
|
||||||
|
return l.classifyState(ctx, a.Text(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Laya) classifyState(ctx context.Context, state string) (*LayaVerdict, error) {
|
||||||
|
raw, err := json.Marshal(layaRequest(state))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(l.URL, "/")+"/predict", bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("laya: invalid URL")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
client := l.Client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("laya: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
||||||
|
return nil, fmt.Errorf("laya: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(detail)))
|
||||||
|
}
|
||||||
|
var lr layaResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
|
||||||
|
return nil, fmt.Errorf("laya: invalid response: %w", err)
|
||||||
|
}
|
||||||
|
return lr.verdict()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lr layaResponse) verdict() (*LayaVerdict, error) {
|
||||||
|
act, ok1 := lr.Answers["remediation_action"]
|
||||||
|
hum, ok2 := lr.Answers["requires_human_attention"]
|
||||||
|
urg, ok3 := lr.Answers["urgency"]
|
||||||
|
if !ok1 || !ok2 || !ok3 || act.Choice == "" {
|
||||||
|
return nil, errors.New("laya: response is missing answers")
|
||||||
|
}
|
||||||
|
v := &LayaVerdict{Model: lr.Model, Action: act.Choice, ActionConfidence: act.Probabilities[act.Choice], ActionProbs: act.Probabilities,
|
||||||
|
Human: hum.Noul, UrgencyScore: urg.Score}
|
||||||
|
// The urgency label is the most probable bucket, not the rounded score:
|
||||||
|
// a 50/50 cosmetic/outage split must not read as "degraded".
|
||||||
|
best := -1.0
|
||||||
|
for k, p := range urg.Probabilities {
|
||||||
|
if p > best || (p == best && k < v.Urgency) {
|
||||||
|
best, v.Urgency = p, k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.UrgencyConf = best
|
||||||
|
if label, ok := urg.Legend[v.Urgency]; ok {
|
||||||
|
v.Urgency = label
|
||||||
|
} else if i, err := strconv.Atoi(v.Urgency); err == nil && i >= 0 && i < len(layaUrgency) {
|
||||||
|
v.Urgency = layaUrgency[i]
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary is a one-line rendering, e.g.
|
||||||
|
// "check_capacity 62% · human 71% · degraded performance 75%".
|
||||||
|
func (v LayaVerdict) Summary() string {
|
||||||
|
return fmt.Sprintf("%s %s · human %s · %s %s", v.Action, pct(v.ActionConfidence), pct(v.Human), v.Urgency, pct(v.UrgencyConf))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunnersUp lists the other actions by probability, for judging how close
|
||||||
|
// the call was.
|
||||||
|
func (v LayaVerdict) RunnersUp() string {
|
||||||
|
type kv struct {
|
||||||
|
k string
|
||||||
|
p float64
|
||||||
|
}
|
||||||
|
var rest []kv
|
||||||
|
for k, p := range v.ActionProbs {
|
||||||
|
if k != v.Action {
|
||||||
|
rest = append(rest, kv{k, p})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(rest, func(i, j int) bool { return rest[i].p > rest[j].p })
|
||||||
|
parts := make([]string, 0, len(rest))
|
||||||
|
for _, r := range rest {
|
||||||
|
parts = append(parts, r.k+" "+pct(r.p))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pct(p float64) string { return strconv.Itoa(int(p*100+0.5)) + "%" }
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-otel/internal/features"
|
||||||
|
)
|
||||||
|
|
||||||
|
// layaReply is the shape laya.k6n.net/predict returned when probed.
|
||||||
|
const layaReply = `{"model":"laya-rl-agent","answers":{
|
||||||
|
"remediation_action":{"type":"choice","choice":"check_capacity","probabilities":{"ignore":0.1,"restart_deployment":0.12,"check_capacity":0.62,"fix_dependency":0.1,"investigate_code":0.06},"confidence":0.3,"answer_confidence":0.62},
|
||||||
|
"requires_human_attention":{"type":"noul","noul":0.7123,"confidence":0.7123,"answer_confidence":0.7123},
|
||||||
|
"urgency":{"type":"score","score":1.03,"legend":{"0":"cosmetic","1":"degraded performance","2":"service outage"},"probabilities":{"0":0.11,"1":0.75,"2":0.14},"confidence":0.32,"answer_confidence":0.75}},
|
||||||
|
"usage":{"input_tokens":141,"output_tokens":0}}`
|
||||||
|
|
||||||
|
func layaServer(t *testing.T, status int, body string, got *map[string]any) *httptest.Server {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/predict" {
|
||||||
|
t.Errorf("path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(got)
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeoutAlert() Alert {
|
||||||
|
now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
return NewAlert(features.Spike{Service: "codesearch", WindowStart: now.Add(-5 * time.Minute), WindowEnd: now, ErrorCount: 15, BaselineMean: 0, Excess: 15,
|
||||||
|
Patterns: []features.PatternDelta{{Signature: "t", Count: 15, Excess: 15, Novel: true,
|
||||||
|
Example: features.Event{Service: "codesearch", Kind: "span", SpanName: "upload", Message: "context deadline exceeded", IsTimeout: true}}}}, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayaClassifySendsAlertTextAndParsesVerdict(t *testing.T) {
|
||||||
|
var req map[string]any
|
||||||
|
srv := layaServer(t, 200, layaReply, &req)
|
||||||
|
a := timeoutAlert()
|
||||||
|
v, err := (&Laya{URL: srv.URL + "/"}).Classify(context.Background(), a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if req["state"] != a.Text("") {
|
||||||
|
t.Fatalf("state is not the alert text:\n%v", req["state"])
|
||||||
|
}
|
||||||
|
qs := req["questions"].(map[string]any)
|
||||||
|
for _, k := range []string{"remediation_action", "requires_human_attention", "urgency"} {
|
||||||
|
if qs[k] == nil {
|
||||||
|
t.Fatalf("question %s missing", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.Action != "check_capacity" || v.ActionConfidence != 0.62 || v.Human != 0.7123 || v.Urgency != "degraded performance" || v.UrgencyConf != 0.75 {
|
||||||
|
t.Fatalf("verdict %+v", v)
|
||||||
|
}
|
||||||
|
if got := v.Summary(); got != "check_capacity 62% · human 71% · degraded performance 75%" {
|
||||||
|
t.Fatalf("summary %q", got)
|
||||||
|
}
|
||||||
|
if got := v.RunnersUp(); !strings.HasPrefix(got, "restart_deployment 12%, ") {
|
||||||
|
t.Fatalf("runners-up %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayaErrors(t *testing.T) {
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
"http": {502, "bad gateway"},
|
||||||
|
"json": {200, "not json"},
|
||||||
|
"missing": {200, `{"answers":{"urgency":{}}}`},
|
||||||
|
} {
|
||||||
|
srv := layaServer(t, tc.status, tc.body, nil)
|
||||||
|
if _, err := (&Laya{URL: srv.URL}).Classify(context.Background(), timeoutAlert()); err == nil {
|
||||||
|
t.Errorf("%s: want error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayaVerdictInNotifications(t *testing.T) {
|
||||||
|
a := timeoutAlert()
|
||||||
|
a.Laya = &LayaVerdict{Action: "restart_deployment", ActionConfidence: 0.8, ActionProbs: map[string]float64{"restart_deployment": 0.8, "ignore": 0.2}, Human: 0.4, Urgency: "service outage", UrgencyConf: 0.6}
|
||||||
|
h := a.HTML("https://otel.example", telegramMax)
|
||||||
|
if !strings.Contains(h, "🤖 <b>laya</b> <i>(shadow, no action taken)</i>: <code>restart_deployment</code> 80% · human 40% · service outage 60%") ||
|
||||||
|
!strings.Contains(h, "other: ignore 20%") {
|
||||||
|
t.Fatalf("html:\n%s", h)
|
||||||
|
}
|
||||||
|
if txt := a.Text(""); !strings.Contains(txt, "laya (shadow, no action taken): restart_deployment 80%") {
|
||||||
|
t.Fatalf("text:\n%s", txt)
|
||||||
|
}
|
||||||
|
a.Laya = nil
|
||||||
|
if strings.Contains(a.HTML("", telegramMax), "laya") {
|
||||||
|
t.Fatal("no verdict must render no laya line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A laya outage must never cost a page; the alert just goes out without a verdict.
|
||||||
|
func TestRunnerAttachesVerdictAndSurvivesLayaFailure(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
status int
|
||||||
|
want bool
|
||||||
|
}{{200, true}, {500, false}} {
|
||||||
|
srv := layaServer(t, tc.status, layaReply, nil)
|
||||||
|
f := &fake{}
|
||||||
|
r := newRunner(nil, f)
|
||||||
|
r.laya = &Laya{URL: srv.URL}
|
||||||
|
r.deliver(context.Background(), timeoutAlert())
|
||||||
|
if len(f.sent) != 1 {
|
||||||
|
t.Fatalf("status %d: sent %d", tc.status, len(f.sent))
|
||||||
|
}
|
||||||
|
if got := f.sent[0].Laya != nil; got != tc.want {
|
||||||
|
t.Fatalf("status %d: verdict attached=%v", tc.status, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ type Alert struct {
|
|||||||
Spike features.Spike `json:"spike"`
|
Spike features.Spike `json:"spike"`
|
||||||
Patterns []PatternReport `json:"patterns"`
|
Patterns []PatternReport `json:"patterns"`
|
||||||
Test bool `json:"test,omitempty"`
|
Test bool `json:"test,omitempty"`
|
||||||
|
// Laya is the laya model's shadow verdict, when it was asked. Advisory
|
||||||
|
// only: nothing is acted on or suppressed because of it.
|
||||||
|
Laya *LayaVerdict `json:"laya,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatternReport is a spike pattern with its triage verdict, ready to show.
|
// PatternReport is a spike pattern with its triage verdict, ready to show.
|
||||||
@@ -214,6 +217,9 @@ func (a Alert) Text(baseURL string) string {
|
|||||||
window := a.Spike.WindowEnd.Sub(a.Spike.WindowStart).Round(time.Minute)
|
window := a.Spike.WindowEnd.Sub(a.Spike.WindowStart).Round(time.Minute)
|
||||||
fmt.Fprintf(&b, "%s: %s is failing (%d errors in the last %s, normally about %.0f)\n",
|
fmt.Fprintf(&b, "%s: %s is failing (%d errors in the last %s, normally about %.0f)\n",
|
||||||
prefix, a.Spike.Service, a.Spike.ErrorCount, window, a.Spike.BaselineMean)
|
prefix, a.Spike.Service, a.Spike.ErrorCount, window, a.Spike.BaselineMean)
|
||||||
|
if a.Laya != nil {
|
||||||
|
fmt.Fprintf(&b, "laya (shadow, no action taken): %s\n", a.Laya.Summary())
|
||||||
|
}
|
||||||
base := strings.TrimRight(baseURL, "/")
|
base := strings.TrimRight(baseURL, "/")
|
||||||
for i, p := range a.Patterns {
|
for i, p := range a.Patterns {
|
||||||
fmt.Fprintf(&b, "\n%d. %s\n", i+1, p.What)
|
fmt.Fprintf(&b, "\n%d. %s\n", i+1, p.What)
|
||||||
@@ -282,6 +288,13 @@ func (a Alert) HTML(baseURL string, max int) string {
|
|||||||
if base != "" {
|
if base != "" {
|
||||||
foot = fmt.Sprintf("\n<a href=\"%s\">All issues</a>", esc(base+"/issues"))
|
foot = fmt.Sprintf("\n<a href=\"%s\">All issues</a>", esc(base+"/issues"))
|
||||||
}
|
}
|
||||||
|
if a.Laya != nil {
|
||||||
|
head += fmt.Sprintf("🤖 <b>laya</b> <i>(shadow, no action taken)</i>: <code>%s</code> %s · human %s · %s %s\n",
|
||||||
|
esc(a.Laya.Action), pct(a.Laya.ActionConfidence), pct(a.Laya.Human), esc(a.Laya.Urgency), pct(a.Laya.UrgencyConf))
|
||||||
|
if r := a.Laya.RunnersUp(); r != "" {
|
||||||
|
head += "<i> other: " + esc(r) + "</i>\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(head)
|
b.WriteString(head)
|
||||||
for i, p := range a.Patterns {
|
for i, p := range a.Patterns {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type Config struct {
|
|||||||
Muted func(issueID string) bool
|
Muted func(issueID string) bool
|
||||||
WebhookURL, WebhookSecret string
|
WebhookURL, WebhookSecret string
|
||||||
AODURL string
|
AODURL string
|
||||||
|
// LayaURL, when set, asks laya (POST {LayaURL}/predict) for a shadow
|
||||||
|
// verdict on every alert and shows it in the notification.
|
||||||
|
LayaURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConfigFromEnv() Config {
|
func ConfigFromEnv() Config {
|
||||||
@@ -54,6 +57,7 @@ func ConfigFromEnv() Config {
|
|||||||
WebhookURL: os.Getenv("OTEL_ALERT_WEBHOOK_URL"),
|
WebhookURL: os.Getenv("OTEL_ALERT_WEBHOOK_URL"),
|
||||||
WebhookSecret: os.Getenv("OTEL_ALERT_WEBHOOK_SECRET"),
|
WebhookSecret: os.Getenv("OTEL_ALERT_WEBHOOK_SECRET"),
|
||||||
AODURL: os.Getenv("OTEL_ALERT_AOD_URL"),
|
AODURL: os.Getenv("OTEL_ALERT_AOD_URL"),
|
||||||
|
LayaURL: os.Getenv("OTEL_ALERT_LAYA_URL"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ type Runner struct {
|
|||||||
dataSince func(ctx context.Context) time.Time
|
dataSince func(ctx context.Context) time.Time
|
||||||
notifiers []Notifier
|
notifiers []Notifier
|
||||||
resolver Resolver // optional: ingress -> service -> deployment for edge errors
|
resolver Resolver // optional: ingress -> service -> deployment for edge errors
|
||||||
|
laya *Laya // optional: shadow verdict attached to each alert
|
||||||
sup *features.Suppressor
|
sup *features.Suppressor
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -137,6 +142,10 @@ func Start(ctx context.Context, cfg Config, conn driver.Conn, database string, r
|
|||||||
},
|
},
|
||||||
dataSince: func(ctx context.Context) time.Time { return DataSince(ctx, conn, database) },
|
dataSince: func(ctx context.Context) time.Time { return DataSince(ctx, conn, database) },
|
||||||
}
|
}
|
||||||
|
if cfg.LayaURL != "" {
|
||||||
|
r.laya = &Laya{URL: cfg.LayaURL}
|
||||||
|
log.Printf("alerts: laya shadow verdicts from %s", cfg.LayaURL)
|
||||||
|
}
|
||||||
names := make([]string, 0, len(notifiers))
|
names := make([]string, 0, len(notifiers))
|
||||||
for _, n := range notifiers {
|
for _, n := range notifiers {
|
||||||
names = append(names, n.Name())
|
names = append(names, n.Name())
|
||||||
@@ -239,6 +248,7 @@ func (r *Runner) deliver(ctx context.Context, a Alert) {
|
|||||||
if pr, ok := r.resolver.(PodResolver); ok {
|
if pr, ok := r.resolver.(PodResolver); ok {
|
||||||
a.EnrichPods(ctx, pr)
|
a.EnrichPods(ctx, pr)
|
||||||
}
|
}
|
||||||
|
r.askLaya(ctx, &a)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.recent = append(r.recent, a)
|
r.recent = append(r.recent, a)
|
||||||
if len(r.recent) > maxRecent {
|
if len(r.recent) > maxRecent {
|
||||||
@@ -261,6 +271,23 @@ func (r *Runner) deliver(ctx context.Context, a Alert) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// askLaya attaches laya's shadow verdict. It is bounded so a slow model
|
||||||
|
// delays a page by seconds at most, and a failure only costs the verdict.
|
||||||
|
func (r *Runner) askLaya(ctx context.Context, a *Alert) {
|
||||||
|
if r.laya == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
v, err := r.laya.Classify(lctx, *a)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("alerts: laya verdict for %s failed: %v", a.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.Laya = v
|
||||||
|
log.Printf("alerts: laya[shadow] %s: %s", a.ID, v.Summary())
|
||||||
|
}
|
||||||
|
|
||||||
// Status is the JSON served by GET /api/alerts.
|
// Status is the JSON served by GET /api/alerts.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -305,6 +332,7 @@ func (r *Runner) HandleTest(w http.ResponseWriter, req *http.Request) {
|
|||||||
Example: features.Event{Service: "alert-test", Kind: "span", StatusCode: 500, Route: "/api/example", Message: "this is a test alert"},
|
Example: features.Event{Service: "alert-test", Kind: "span", StatusCode: 500, Route: "/api/example", Message: "this is a test alert"},
|
||||||
}}}, now)
|
}}}, now)
|
||||||
a.Test = true
|
a.Test = true
|
||||||
|
r.askLaya(req.Context(), &a)
|
||||||
results := map[string]string{}
|
results := map[string]string{}
|
||||||
for _, n := range r.notifiers {
|
for _, n := range r.notifiers {
|
||||||
if err := n.Send(req.Context(), a); err != nil {
|
if err := n.Send(req.Context(), a); err != nil {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -1007,110 +1006,6 @@ func (c *Collector) HandleStatsEndpoints(w http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// needleAddr returns the configured or default address for the Needle 3 sidecar.
|
|
||||||
func needleAddr() string {
|
|
||||||
addr := os.Getenv("OTEL_NEEDLE_ADDR")
|
|
||||||
if addr == "" {
|
|
||||||
addr = "http://127.0.0.1:5005"
|
|
||||||
}
|
|
||||||
return strings.TrimRight(addr, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleNeedleStatus checks if the Needle 3 sidecar is reachable and reports its health.
|
|
||||||
func (c *Collector) HandleNeedleStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target := needleAddr() + "/health"
|
|
||||||
client := &http.Client{Timeout: 2 * time.Second}
|
|
||||||
resp, err := client.Get(target)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"available": false,
|
|
||||||
"error": fmt.Sprintf("Needle sidecar unreachable at %s: %v", target, err),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
_, _ = io.Copy(w, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleNeedleClassify proxies telemetry triage requests to the Needle 3 sidecar.
|
|
||||||
func (c *Collector) HandleNeedleClassify(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to read request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the payload specifies an issue_id and no text, look up the issue
|
|
||||||
var reqMap map[string]interface{}
|
|
||||||
if err := json.Unmarshal(body, &reqMap); err == nil {
|
|
||||||
if issueID, ok := reqMap["issue_id"].(string); ok && issueID != "" {
|
|
||||||
if issue, ok := c.GetIssue(issueID); ok {
|
|
||||||
var promptParts []string
|
|
||||||
promptParts = append(promptParts, fmt.Sprintf("Service: %s", issue.Service))
|
|
||||||
promptParts = append(promptParts, fmt.Sprintf("Title: %s", issue.Title))
|
|
||||||
promptParts = append(promptParts, fmt.Sprintf("Kind: %s", issue.Kind))
|
|
||||||
if len(issue.Samples) > 0 {
|
|
||||||
latest := issue.Samples[len(issue.Samples)-1]
|
|
||||||
if latest.Message != "" {
|
|
||||||
promptParts = append(promptParts, fmt.Sprintf("Message: %s", latest.Message))
|
|
||||||
}
|
|
||||||
if latest.URL != "" {
|
|
||||||
promptParts = append(promptParts, fmt.Sprintf("URL: %s", latest.URL))
|
|
||||||
}
|
|
||||||
if latest.HTTPStatus > 0 {
|
|
||||||
promptParts = append(promptParts, fmt.Sprintf("Status: %d", latest.HTTPStatus))
|
|
||||||
}
|
|
||||||
if latest.HTTPRoute != "" {
|
|
||||||
promptParts = append(promptParts, fmt.Sprintf("Route: %s", latest.HTTPRoute))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reqMap["text"] = strings.Join(promptParts, " | ")
|
|
||||||
reqMap["service"] = issue.Service
|
|
||||||
reqMap["title"] = issue.Title
|
|
||||||
body, _ = json.Marshal(reqMap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
target := needleAddr() + "/classify"
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target, strings.NewReader(string(body)))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to create proxy request", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxyReq.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := client.Do(proxyReq)
|
|
||||||
if err != nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("Needle sidecar unreachable at %s: %v", target, err),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
_, _ = io.Copy(w, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stampFrontendClient fills in client.address and user_agent.original on
|
// stampFrontendClient fills in client.address and user_agent.original on
|
||||||
// browser-originated log records (source=frontend) from the ingest request,
|
// browser-originated log records (source=frontend) from the ingest request,
|
||||||
// since the browser cannot know its own public IP. Values the SDK already
|
// since the browser cannot know its own public IP. Values the SDK already
|
||||||
|
|||||||
+6
-48
@@ -15,6 +15,9 @@ spec:
|
|||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: otel-debug-receiver
|
app: otel-debug-receiver
|
||||||
|
annotations:
|
||||||
|
k8s-helper.io/git-url: "https://git.k6n.net/mats/go-otel"
|
||||||
|
k8s-helper.io/git-ssh: "git@git.k6n.net:mats/go-otel.git"
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: otel-debug-receiver
|
serviceAccountName: otel-debug-receiver
|
||||||
securityContext:
|
securityContext:
|
||||||
@@ -138,6 +141,9 @@ spec:
|
|||||||
# Optional Always-On Display session API.
|
# Optional Always-On Display session API.
|
||||||
- name: OTEL_ALERT_AOD_URL
|
- name: OTEL_ALERT_AOD_URL
|
||||||
value: ""
|
value: ""
|
||||||
|
# laya shadow verdicts: shown in each notification, never acted on.
|
||||||
|
- name: OTEL_ALERT_LAYA_URL
|
||||||
|
value: "https://laya.k6n.net"
|
||||||
- name: OTEL_STORE_WORKERS
|
- name: OTEL_STORE_WORKERS
|
||||||
value: "4"
|
value: "4"
|
||||||
# Optional rollout observation. The watcher is read-only and uses the
|
# Optional rollout observation. The watcher is read-only and uses the
|
||||||
@@ -167,8 +173,6 @@ spec:
|
|||||||
value: "15m"
|
value: "15m"
|
||||||
- name: OTEL_HONEYPOT_TTL
|
- name: OTEL_HONEYPOT_TTL
|
||||||
value: "1h"
|
value: "1h"
|
||||||
- name: OTEL_NEEDLE_ADDR
|
|
||||||
value: "http://127.0.0.1:5005"
|
|
||||||
# ~80% of the memory limit so the GC works harder before an OOMKill.
|
# ~80% of the memory limit so the GC works harder before an OOMKill.
|
||||||
- name: GOMEMLIMIT
|
- name: GOMEMLIMIT
|
||||||
value: "800MiB"
|
value: "800MiB"
|
||||||
@@ -196,58 +200,12 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
memory: "1Gi"
|
memory: "1Gi"
|
||||||
cpu: "500m"
|
cpu: "500m"
|
||||||
- name: needle-sidecar
|
|
||||||
image: matst80/needle-sidecar:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- containerPort: 5005
|
|
||||||
name: needle-http
|
|
||||||
env:
|
|
||||||
- name: HOME
|
|
||||||
value: "/app"
|
|
||||||
- name: NEEDLE_PORT
|
|
||||||
value: "5005"
|
|
||||||
- name: NEEDLE_HOST
|
|
||||||
value: "0.0.0.0"
|
|
||||||
- name: NEEDLE_TELEMETRY
|
|
||||||
value: "0"
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: needle-http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: needle-http
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "200m"
|
|
||||||
limits:
|
|
||||||
memory: "2Gi"
|
|
||||||
cpu: "4000m"
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: log-dir
|
- name: log-dir
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
- name: sourcemap-dir
|
- name: sourcemap-dir
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: otel-sourcemaps
|
claimName: otel-sourcemaps
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: otel-sourcemaps
|
|
||||||
namespace: monitoring
|
|
||||||
spec:
|
|
||||||
accessModes: ["ReadWriteOnce"]
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 1Gi
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# OIDC Registration in ecommerce namespace
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: flux-dashboard-oidc-client-secret
|
||||||
|
namespace: ecommerce
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
secret: "f9f44c88d9fda647e4abbb4479f1116611ecc14624bd67ec"
|
||||||
|
---
|
||||||
|
apiVersion: auth.k6n.net/v1alpha1
|
||||||
|
kind: AuthClient
|
||||||
|
metadata:
|
||||||
|
name: flux-dashboard
|
||||||
|
namespace: ecommerce
|
||||||
|
labels:
|
||||||
|
auth.k6n.net/server: auth
|
||||||
|
spec:
|
||||||
|
clientID: flux-dashboard
|
||||||
|
displayName: Flux GitOps Dashboard
|
||||||
|
description: Web UI for Flux CD (Weave GitOps)
|
||||||
|
redirectURIs:
|
||||||
|
- https://flux.k6n.net/oauth2/callback
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- profile
|
||||||
|
- email
|
||||||
|
- groups
|
||||||
|
secretRef:
|
||||||
|
name: flux-dashboard-oidc-client-secret
|
||||||
|
key: secret
|
||||||
|
---
|
||||||
|
# Weave GitOps OIDC Secret in flux-system
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: oidc-auth
|
||||||
|
namespace: flux-system
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
clientID: "flux-dashboard"
|
||||||
|
clientSecret: "f9f44c88d9fda647e4abbb4479f1116611ecc14624bd67ec"
|
||||||
|
issuerURL: "https://users.k6n.net"
|
||||||
|
redirectURL: "https://flux.k6n.net/oauth2/callback"
|
||||||
|
claimUsername: "email"
|
||||||
|
claimGroups: "roles"
|
||||||
|
---
|
||||||
|
# Weave GitOps Helm OCI Source and HelmRelease
|
||||||
|
apiVersion: source.toolkit.fluxcd.io/v1
|
||||||
|
kind: HelmRepository
|
||||||
|
metadata:
|
||||||
|
name: weave-gitops
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
type: oci
|
||||||
|
url: oci://ghcr.io/weaveworks/charts
|
||||||
|
interval: 1h
|
||||||
|
---
|
||||||
|
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||||
|
kind: HelmRelease
|
||||||
|
metadata:
|
||||||
|
name: weave-gitops
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
chart:
|
||||||
|
spec:
|
||||||
|
chart: weave-gitops
|
||||||
|
version: "4.0.36"
|
||||||
|
sourceRef:
|
||||||
|
kind: HelmRepository
|
||||||
|
name: weave-gitops
|
||||||
|
values:
|
||||||
|
envVars:
|
||||||
|
- name: KUBERNETES_SERVICE_HOST
|
||||||
|
value: "10.10.1.102"
|
||||||
|
- name: KUBERNETES_SERVICE_PORT
|
||||||
|
value: "6443"
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- serber
|
||||||
|
# Use existing oidc-auth secret created above
|
||||||
|
oidcSecret:
|
||||||
|
create: false
|
||||||
|
adminUser:
|
||||||
|
create: false
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: flux.k6n.net
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: ImplementationSpecific
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- flux.k6n.net
|
||||||
|
secretName: flux-k6n-tls
|
||||||
|
---
|
||||||
|
# ClusterRoleBinding so authenticated users can view/manage GitOps resources
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: weave-gitops-admin
|
||||||
|
subjects:
|
||||||
|
- kind: Group
|
||||||
|
name: admin
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
roleRef:
|
||||||
|
kind: ClusterRole
|
||||||
|
name: cluster-admin
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: source.toolkit.fluxcd.io/v1
|
||||||
|
kind: GitRepository
|
||||||
|
metadata:
|
||||||
|
name: go-otel
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 1m
|
||||||
|
url: ssh://git@git.k6n.net/mats/go-otel.git
|
||||||
|
ref:
|
||||||
|
branch: main
|
||||||
|
secretRef:
|
||||||
|
name: go-otel-auth
|
||||||
|
---
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: go-otel
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 5m
|
||||||
|
targetNamespace: monitoring
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: go-otel
|
||||||
|
path: "./k8s"
|
||||||
|
prune: true
|
||||||
|
wait: true
|
||||||
|
timeout: 3m
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ingress-catchall
|
||||||
|
namespace: ingress-nginx
|
||||||
|
data:
|
||||||
|
nginx.conf: |
|
||||||
|
worker_processes 1;
|
||||||
|
pid /tmp/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_body_temp_path /tmp/client_temp;
|
||||||
|
proxy_temp_path /tmp/proxy_temp;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||||
|
scgi_temp_path /tmp/scgi_temp;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
error_page 403 =404 /index.html;
|
||||||
|
|
||||||
|
# Only GET is intentionally accepted by this catch-all.
|
||||||
|
# The "if" is safe here because it only performs a return.
|
||||||
|
# Serve robots.txt directly with Disallow: /
|
||||||
|
location = /robots.txt {
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "User-agent: *\nDisallow: /\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deliberately trigger an internal 403 so the static error page is
|
||||||
|
# returned with the externally visible status still set to 404.
|
||||||
|
location / {
|
||||||
|
if ($request_method != GET) {
|
||||||
|
add_header Allow GET always;
|
||||||
|
return 405;
|
||||||
|
}
|
||||||
|
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the error document internal so /index.html also follows the
|
||||||
|
# catch-all policy instead of becoming a successful public endpoint.
|
||||||
|
location = /index.html {
|
||||||
|
internal;
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index.html: |
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Not found</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #e8ecf2;
|
||||||
|
background: #0d1014;
|
||||||
|
font: 16px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
main { width: min(100%, 42rem); }
|
||||||
|
.code {
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
color: #7b8cff;
|
||||||
|
font: 700 clamp(4rem, 14vw, 8rem)/.9 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: -.08em;
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 .75rem; font-size: clamp(1.5rem, 4vw, 2.25rem); }
|
||||||
|
p { margin: 0; max-width: 34rem; color: #98a3b2; }
|
||||||
|
code { color: #e8ecf2; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<p class="code">404</p>
|
||||||
|
<h1>There is nothing here.</h1>
|
||||||
|
<p>The requested resource does not exist on this ingress endpoint.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ingress-catchall
|
||||||
|
namespace: ingress-nginx
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ingress-catchall
|
||||||
|
app.kubernetes.io/part-of: ingress-nginx
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: ingress-catchall
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ingress-catchall
|
||||||
|
app.kubernetes.io/part-of: ingress-nginx
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: nginx
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 101
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/nginx/nginx.conf
|
||||||
|
subPath: nginx.conf
|
||||||
|
readOnly: true
|
||||||
|
- name: config
|
||||||
|
mountPath: /usr/share/nginx/html/index.html
|
||||||
|
subPath: index.html
|
||||||
|
readOnly: true
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: http
|
||||||
|
periodSeconds: 5
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: ingress-catchall
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ingress-catchall
|
||||||
|
namespace: ingress-nginx
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ingress-catchall
|
||||||
|
app.kubernetes.io/part-of: ingress-nginx
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: ingress-catchall
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: http
|
||||||
@@ -85,6 +85,10 @@ metadata:
|
|||||||
name: otel-debug-ingress
|
name: otel-debug-ingress
|
||||||
namespace: monitoring
|
namespace: monitoring
|
||||||
annotations:
|
annotations:
|
||||||
|
k8s-helper.io/name: "Go OTel"
|
||||||
|
k8s-helper.io/description: "OpenTelemetry debugging dashboard"
|
||||||
|
k8s-helper.io/group: "Observability"
|
||||||
|
k8s-helper.io/icon: "activity"
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
# OIDC protection via the platform auth service (users.k6n.net):
|
# OIDC protection via the platform auth service (users.k6n.net):
|
||||||
# /oauth2/auth resolves the session cookie / bearer token and answers
|
# /oauth2/auth resolves the session cookie / bearer token and answers
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- ingress.yaml
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: otel-sourcemaps
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
ShieldBan,
|
ShieldBan,
|
||||||
Bot,
|
Bot,
|
||||||
Sparkles,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -38,7 +37,6 @@ import { useSubRoute } from "../lib/routes"
|
|||||||
import { CopyRef } from "./common/CopyRef"
|
import { CopyRef } from "./common/CopyRef"
|
||||||
import { IpButton } from "./common/IpButton"
|
import { IpButton } from "./common/IpButton"
|
||||||
import { IssueOccurrences } from "./IssueOccurrences"
|
import { IssueOccurrences } from "./IssueOccurrences"
|
||||||
import { NeedleTestModal } from "./NeedleTestModal"
|
|
||||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||||
import { CRAWLER_CLASSES, UA_CLASSES, uaClassLabel } from "../lib/ua-class"
|
import { CRAWLER_CLASSES, UA_CLASSES, uaClassLabel } from "../lib/ua-class"
|
||||||
import type { Issue, IssueSample } from "../lib/types"
|
import type { Issue, IssueSample } from "../lib/types"
|
||||||
@@ -94,16 +92,6 @@ interface DetectorTestResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NeedleResult {
|
|
||||||
action?: string
|
|
||||||
tool?: string
|
|
||||||
confidence?: number
|
|
||||||
reasoning?: string
|
|
||||||
latency_ms?: number
|
|
||||||
arguments?: Record<string, unknown>
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusTabs: { value: StatusFilter; label: string }[] = [
|
const statusTabs: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "open", label: "Open" },
|
{ value: "open", label: "Open" },
|
||||||
@@ -315,26 +303,8 @@ const IssueCard = ({
|
|||||||
detectedIPByAddress: Map<string, DetectedIP>
|
detectedIPByAddress: Map<string, DetectedIP>
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [needleResult, setNeedleResult] = useState<NeedleResult | null>(null)
|
|
||||||
const [needleLoading, setNeedleLoading] = useState(false)
|
|
||||||
const { sources, loading: sourcesLoading, error: sourcesError } = useIssueSources(issue.id, expanded)
|
const { sources, loading: sourcesLoading, error: sourcesError } = useIssueSources(issue.id, expanded)
|
||||||
|
|
||||||
const handleNeedleTriage = async () => {
|
|
||||||
setNeedleLoading(true)
|
|
||||||
try {
|
|
||||||
const response = await authFetch("/api/needle/classify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ issue_id: issue.id }),
|
|
||||||
})
|
|
||||||
setNeedleResult(await response.json())
|
|
||||||
} catch (err) {
|
|
||||||
setNeedleResult({ error: err instanceof Error ? err.message : String(err) })
|
|
||||||
} finally {
|
|
||||||
setNeedleLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug }
|
const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug }
|
||||||
const tone = issueSeverityTone(issue)
|
const tone = issueSeverityTone(issue)
|
||||||
const latestSample = issue.samples[issue.samples.length - 1]
|
const latestSample = issue.samples[issue.samples.length - 1]
|
||||||
@@ -505,57 +475,6 @@ const IssueCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleNeedleTriage()}
|
|
||||||
disabled={needleLoading}
|
|
||||||
title="Classify this issue using local Needle 3 model"
|
|
||||||
className="inline-flex h-8 items-center gap-2 rounded-[9px] border border-ds-line-strong px-3 text-[12px] font-medium text-ds-text-2 hover:text-ds-text disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{needleLoading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
{needleLoading ? "Needle 3 triaging…" : "Needle 3 triage"}
|
|
||||||
</button>
|
|
||||||
{needleResult && (
|
|
||||||
<div className="space-y-2 rounded-[12px] border border-ds-line bg-ds-surface-2 p-3 text-[12px]">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ds-line pb-2">
|
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-[10.5px] font-bold uppercase tracking-wider text-ds-text-3">Needle 3 action</span>
|
|
||||||
<span className="rounded-[6px] border border-ds-line-strong px-2 py-0.5 font-[family-name:var(--font-data)] text-ds-text">
|
|
||||||
{needleResult.action || needleResult.tool || "Processed"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{needleResult.confidence !== undefined && (
|
|
||||||
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
|
||||||
confidence{" "}
|
|
||||||
<span className={needleResult.confidence >= 0.6 ? "text-ds-ok" : "text-ds-warn"}>
|
|
||||||
{(needleResult.confidence * 100).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
{needleResult.latency_ms ? ` (${needleResult.latency_ms}ms)` : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{needleResult.reasoning && <p className="italic text-ds-text-2">“{needleResult.reasoning}”</p>}
|
|
||||||
{needleResult.arguments && Object.keys(needleResult.arguments).length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 font-[family-name:var(--font-data)] text-[11px]">
|
|
||||||
{Object.entries(needleResult.arguments).map(([k, v]) => (
|
|
||||||
<span key={k} className="rounded-[6px] border border-ds-line bg-ds-surface px-2 py-0.5 text-ds-text-2">
|
|
||||||
<span className="text-ds-text-3">{k}:</span> {String(v)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{needleResult.error && (
|
|
||||||
<p className="font-[family-name:var(--font-data)] text-[11px] text-ds-critical">{needleResult.error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||||||
@@ -750,7 +669,6 @@ export const IssuesVisualizer = ({
|
|||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||||
const [groupBy, setGroupBy] = useState<GroupBy>("none")
|
const [groupBy, setGroupBy] = useState<GroupBy>("none")
|
||||||
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
|
||||||
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
|
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
|
||||||
const [detectorTestLoading, setDetectorTestLoading] = useState(false)
|
const [detectorTestLoading, setDetectorTestLoading] = useState(false)
|
||||||
const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
|
const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
|
||||||
@@ -1119,15 +1037,6 @@ export const IssuesVisualizer = ({
|
|||||||
<Bot className="h-3.5 w-3.5" aria-hidden="true" />
|
<Bot className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
{crawlersHidden ? "Crawlers hidden" : "Hide crawlers"}
|
{crawlersHidden ? "Crawlers hidden" : "Hide crawlers"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setNeedleModalOpen(true)}
|
|
||||||
title="Open interactive Needle 3 telemetry classifier"
|
|
||||||
className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text"
|
|
||||||
>
|
|
||||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
Test Needle 3
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void runDetectorTest()}
|
onClick={() => void runDetectorTest()}
|
||||||
@@ -1295,7 +1204,6 @@ export const IssuesVisualizer = ({
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NeedleTestModal isOpen={needleModalOpen} onClose={() => setNeedleModalOpen(false)} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
import React, { useState } from "react"
|
|
||||||
import { X, Sparkles, Loader2, Cpu, CheckCircle2, AlertTriangle } from "lucide-react"
|
|
||||||
import { authFetch } from "../lib/auth"
|
|
||||||
|
|
||||||
interface NeedleTestModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NeedleResponse {
|
|
||||||
success?: boolean
|
|
||||||
action?: string
|
|
||||||
tool?: string
|
|
||||||
confidence?: number
|
|
||||||
reasoning?: string
|
|
||||||
latency_ms?: number
|
|
||||||
arguments?: Record<string, unknown>
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRESETS = [
|
|
||||||
{
|
|
||||||
label: "Frontend RUM Crash",
|
|
||||||
service: "eventsourced-ai-web",
|
|
||||||
text: "User reported client browser crash on service eventsourced-ai-web: Script error on url https://event-agent.k6n.net/#session=angry-koala-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Security CVE Probe",
|
|
||||||
service: "nginx",
|
|
||||||
text: "Alert security team: scanner metabase-cve-2026-72898-detect/1.0 probed target /api/session/properties with status code 503",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Web Vital Metric",
|
|
||||||
service: "storefront-web",
|
|
||||||
text: "Store RUM metric on service storefront-web: record TTFB of 84 ms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Benign 404 Route",
|
|
||||||
service: "git.k6n.net",
|
|
||||||
text: "Client requested missing git resource: 404 on path /mats/go-cart-actor/blame/commit/81569b on host git.k6n.net",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Database Pool Deadlock",
|
|
||||||
service: "checkout-service",
|
|
||||||
text: "Critical incident: Escalate severe backend database timeout on service checkout-service: deadlocked while acquiring connection pool",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const NeedleTestModal: React.FC<NeedleTestModalProps> = ({ isOpen, onClose }) => {
|
|
||||||
const [inputText, setInputText] = useState(PRESETS[0].text)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [result, setResult] = useState<NeedleResponse | null>(null)
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const handleClassify = async () => {
|
|
||||||
if (!inputText.trim()) return
|
|
||||||
setLoading(true)
|
|
||||||
setResult(null)
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/needle/classify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ text: inputText }),
|
|
||||||
})
|
|
||||||
const data = await resp.json()
|
|
||||||
setResult(data)
|
|
||||||
} catch (err) {
|
|
||||||
setResult({ error: err instanceof Error ? err.message : String(err) })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ds-bg/80 p-4 animate-fade-in">
|
|
||||||
<div className="relative flex flex-col w-full max-w-2xl rounded-[14px] border border-ds-ok/30 bg-ds-bg/95 p-6 max-h-[90vh] overflow-y-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-ds-line pb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-ds-ok/15 border border-ds-ok/30 text-ds-ok">
|
|
||||||
<Cpu className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold text-ds-text flex items-center gap-2">
|
|
||||||
Needle 3 AI Telemetry Classifier
|
|
||||||
<span className="rounded-full bg-ds-ok/15 border border-ds-ok/30 px-2 py-0.5 text-[10px] font-mono text-ds-ok">
|
|
||||||
Cactus-Compute
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-ds-text-2">
|
|
||||||
On-device ~35MB foundation model executing real-time tool calling and extraction on local CPU.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg p-1.5 text-ds-text-2 hover:bg-ds-fill hover:text-ds-text transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preset Selector */}
|
|
||||||
<div className="mt-4">
|
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-ds-text-2">
|
|
||||||
Quick Telemetry Presets (from ClickHouse)
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{PRESETS.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setInputText(p.text)
|
|
||||||
setResult(null)
|
|
||||||
}}
|
|
||||||
className={`rounded-lg border px-2.5 py-1 text-[11px] font-medium transition-all ${
|
|
||||||
inputText === p.text
|
|
||||||
? "border-ds-ok bg-ds-ok/15 text-ds-ok"
|
|
||||||
: "border-ds-line bg-ds-surface-2 text-ds-text-2 hover:border-ds-line-strong hover:bg-ds-fill"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Textarea */}
|
|
||||||
<div className="mt-4">
|
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-ds-text-2">
|
|
||||||
Telemetry Event / Log / Trace Summary
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={inputText}
|
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="mt-1.5 w-full rounded-xl border border-ds-line bg-ds-surface-2 p-3 font-mono text-xs text-ds-text placeholder:text-ds-text-3 focus:border-ds-ok/50 focus:outline-none focus:ring-1 focus:ring-ds-ok/40"
|
|
||||||
placeholder="Type or paste telemetry event..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Classify Button */}
|
|
||||||
<div className="mt-3 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClassify}
|
|
||||||
disabled={loading || !inputText.trim()}
|
|
||||||
className="inline-flex items-center gap-2 rounded-xl bg-ds-surface-2 px-4 py-2 text-xs font-bold text-ds-on-accent transition-all hover:opacity-95 active:scale-95 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Classifying on CPU...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
Classify with Needle 3
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results Area */}
|
|
||||||
{result && (
|
|
||||||
<div className="mt-5 rounded-xl border border-ds-ok/30 bg-ds-ok/15 p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between border-b border-ds-ok/20 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{result.error ? (
|
|
||||||
<AlertTriangle className="h-4 w-4 text-ds-critical" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-ds-ok" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-ds-text-2">
|
|
||||||
{result.error ? "Error" : "Triage Result"}
|
|
||||||
</span>
|
|
||||||
{result.action && (
|
|
||||||
<span className="rounded-lg bg-ds-ok/15 border border-ds-ok/40 px-2 py-0.5 font-mono text-xs font-bold text-ds-ok">
|
|
||||||
{result.action}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{result.confidence !== undefined && (
|
|
||||||
<div className="flex items-center gap-3 text-xs font-mono">
|
|
||||||
<span className="text-ds-text-2">
|
|
||||||
Confidence:{" "}
|
|
||||||
<span className="font-bold text-ds-ok">
|
|
||||||
{(result.confidence * 100).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{result.latency_ms !== undefined && (
|
|
||||||
<span className="text-ds-text-3">({result.latency_ms}ms)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result.reasoning && (
|
|
||||||
<p className="text-xs text-ds-text-2 italic">
|
|
||||||
“{result.reasoning}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.arguments && Object.keys(result.arguments).length > 0 && (
|
|
||||||
<div className="space-y-1.5 pt-1">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-ds-text-2">
|
|
||||||
Extracted Typed Arguments:
|
|
||||||
</span>
|
|
||||||
<div className="grid grid-cols-2 gap-2 font-mono text-xs">
|
|
||||||
{Object.entries(result.arguments).map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="rounded-lg border border-ds-line bg-ds-surface-2 p-2 text-ds-text break-all"
|
|
||||||
>
|
|
||||||
<span className="text-ds-text-3">{key}: </span>
|
|
||||||
<span className="text-ds-ok font-semibold">{String(value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.error && (
|
|
||||||
<div className="rounded-lg bg-ds-critical-bg border border-ds-critical/20 p-3 text-xs text-ds-critical">
|
|
||||||
{result.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { build } from 'esbuild';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
await build({
|
||||||
|
absWorkingDir: fileURLToPath(new URL('.', import.meta.url)),
|
||||||
|
entryPoints: ['src/otel-rum.ts', 'src/otel-todo.ts'],
|
||||||
|
outdir: '../static',
|
||||||
|
bundle: true,
|
||||||
|
format: 'iife',
|
||||||
|
platform: 'browser',
|
||||||
|
target: ['es2020'],
|
||||||
|
minify: true,
|
||||||
|
sourcemap: 'external',
|
||||||
|
logLevel: 'info',
|
||||||
|
});
|
||||||
Generated
+512
@@ -0,0 +1,512 @@
|
|||||||
|
{
|
||||||
|
"name": "go-otel-rum",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "go-otel-rum",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "go-otel-rum",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "npm run typecheck && node build.mjs",
|
||||||
|
"test": "npm run build && node --test ../scripts/otel-rum.test.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
import type { Attributes, BrowserGlobal, Consent, ExceptionLike, RumConfig, VitalMetric } from './types';
|
||||||
|
|
||||||
|
interface Breadcrumb {
|
||||||
|
t: number;
|
||||||
|
category: string;
|
||||||
|
message: string;
|
||||||
|
d?: number;
|
||||||
|
tid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogRecord {
|
||||||
|
timeUnixNano: string;
|
||||||
|
severityText: string;
|
||||||
|
severityNumber: number;
|
||||||
|
body: { stringValue: string };
|
||||||
|
attributes: { key: string; value: { stringValue: string } }[];
|
||||||
|
traceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function (global: BrowserGlobal) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var DEFAULT_BASE_URL = 'https://otel.k6n.net';
|
||||||
|
|
||||||
|
// Find the loading script so declarative data-* configuration keeps working.
|
||||||
|
var scriptEl: HTMLScriptElement | null = null;
|
||||||
|
try {
|
||||||
|
scriptEl = (typeof document !== 'undefined') ? (document.currentScript as HTMLScriptElement | null || (function() {
|
||||||
|
var scripts = document.getElementsByTagName('script');
|
||||||
|
for (var i = scripts.length - 1; i >= 0; i--) {
|
||||||
|
if (scripts[i].src && scripts[i].src.indexOf('otel-rum') !== -1) return scripts[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()) : null;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
var rawFetch = typeof global.fetch === 'function' ? global.fetch : null;
|
||||||
|
var rawXHR = typeof global.XMLHttpRequest === 'function' ? global.XMLHttpRequest : null;
|
||||||
|
|
||||||
|
function randomHex(len: number) {
|
||||||
|
var chars = '0123456789abcdef';
|
||||||
|
var out = '';
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
out += chars[(Math.random() * 16) | 0];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// W3C Trace Context ids: 16-byte trace id, 8-byte span id, both hex.
|
||||||
|
function newTraceId() {
|
||||||
|
return randomHex(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSpanId() {
|
||||||
|
return randomHex(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOrigin(url: string) {
|
||||||
|
try {
|
||||||
|
var resolved = new global.URL(url, global.location.href);
|
||||||
|
return resolved.origin === global.location.origin;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOtlpAttributes(obj: Attributes) {
|
||||||
|
var out = [];
|
||||||
|
for (var key in obj) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
||||||
|
var value = obj[key];
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
out.push({ key: key, value: { stringValue: String(value) } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(config: RumConfig = {}) {
|
||||||
|
config = config || {};
|
||||||
|
|
||||||
|
var scriptServiceName = (scriptEl && (scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) || '';
|
||||||
|
var scriptBaseUrl = (scriptEl && scriptEl.getAttribute('data-base-url')) || '';
|
||||||
|
var scriptEndpoint = (scriptEl && scriptEl.getAttribute('data-endpoint')) || '';
|
||||||
|
var scriptIngestKey = (scriptEl && scriptEl.getAttribute('data-ingest-key')) || '';
|
||||||
|
|
||||||
|
// Collector base URL resolution:
|
||||||
|
// 1. Explicit config.baseUrl or scriptEl data-base-url
|
||||||
|
// 2. Production collector: https://otel.k6n.net
|
||||||
|
// Do not derive this from the page or script origin: the script may be
|
||||||
|
// bundled or self-hosted while logs still belong at the collector.
|
||||||
|
var baseUrl = (config.baseUrl || scriptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||||
|
var endpoint = config.endpoint || scriptEndpoint;
|
||||||
|
if (!endpoint) {
|
||||||
|
endpoint = baseUrl + '/v1/logs';
|
||||||
|
} else if (!/^https?:\/\//i.test(endpoint) && !endpoint.startsWith('//')) {
|
||||||
|
endpoint = baseUrl + '/' + endpoint.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
var vitalsEndpoint = config.vitalsEndpoint || (scriptEl && scriptEl.getAttribute('data-vitals-endpoint')) || baseUrl + '/v1/vitals';
|
||||||
|
// The web-vitals build lives next to this script; fall back to the collector's /static.
|
||||||
|
var vitalsLibUrl =
|
||||||
|
config.vitalsLibUrl ||
|
||||||
|
(scriptEl && scriptEl.getAttribute('data-vitals-lib')) ||
|
||||||
|
(scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'web-vitals.attribution.iife.js' : baseUrl + '/static/web-vitals.attribution.iife.js');
|
||||||
|
|
||||||
|
function isCollectorUrl(u: string | URL) {
|
||||||
|
if (!u) return false;
|
||||||
|
var s = String(u);
|
||||||
|
if (endpoint && s.indexOf(endpoint) === 0) return true;
|
||||||
|
if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1 || s.indexOf('/v1/vitals') !== -1) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceName = config.serviceName || scriptServiceName || 'frontend';
|
||||||
|
var environment = config.environment || (scriptEl && scriptEl.getAttribute('data-environment')) || '';
|
||||||
|
var release = config.release || (scriptEl && scriptEl.getAttribute('data-release')) || '';
|
||||||
|
var ingestKey = config.ingestKey || scriptIngestKey || '';
|
||||||
|
var consent: Consent = (config.consent || (scriptEl && scriptEl.getAttribute('data-consent'))) === 'granted' ? 'granted' : 'denied';
|
||||||
|
var devRequested = typeof config.devMode === 'boolean' ? config.devMode : !!(scriptEl && scriptEl.getAttribute('data-dev-mode') === 'true');
|
||||||
|
var devHosts = config.devModeHosts || ((scriptEl && scriptEl.getAttribute('data-dev-mode-hosts')) || '').split(',').map(function (host) { return host.trim(); }).filter(Boolean);
|
||||||
|
var devMode = false;
|
||||||
|
try {
|
||||||
|
devMode = devRequested && devHosts.indexOf(new global.URL(global.location.href).hostname) !== -1;
|
||||||
|
} catch (e) {}
|
||||||
|
var minimalDiagnostics = config.minimalDiagnostics;
|
||||||
|
var consentListeners: (() => void)[] = [];
|
||||||
|
var sampleRate = typeof config.sampleRate === 'number' ? config.sampleRate : 1;
|
||||||
|
var maxBreadcrumbs = config.maxBreadcrumbs || 20;
|
||||||
|
var captureConsoleErrors = config.captureConsoleErrors !== false;
|
||||||
|
var autoInstrumentFetch = config.autoInstrumentFetch !== false;
|
||||||
|
var autoInstrumentXhr = config.autoInstrumentXhr !== false;
|
||||||
|
var trackSpaRoutes = config.trackSpaRoutes !== false;
|
||||||
|
var reportHttpFailures = config.reportHttpFailures !== false;
|
||||||
|
var captureWebVitals = config.captureWebVitals !== false;
|
||||||
|
var captureWebVitalsWithoutConsent = typeof config.captureWebVitalsWithoutConsent === 'boolean' ? config.captureWebVitalsWithoutConsent : !!(scriptEl && scriptEl.getAttribute('data-vitals-without-consent') === 'true');
|
||||||
|
var vitalsStarted = false;
|
||||||
|
var enableTodo = typeof config.enableTodo === 'boolean' ? config.enableTodo : !!(scriptEl && scriptEl.getAttribute('data-enable-todo') === 'true');
|
||||||
|
var excludeTodo = config.excludeTodo === true || (scriptEl && scriptEl.getAttribute('data-exclude-todo') === 'true');
|
||||||
|
var todoLibUrl =
|
||||||
|
config.todoLibUrl ||
|
||||||
|
(scriptEl && scriptEl.getAttribute('data-todo-lib')) ||
|
||||||
|
(scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'otel-todo.js' : baseUrl + '/static/otel-todo.js');
|
||||||
|
var injectTraceHeaders = config.injectTraceHeaders !== false;
|
||||||
|
var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only
|
||||||
|
// Client-side rate limiting: max events per rolling minute.
|
||||||
|
var maxEventsPerMinute = config.maxEventsPerMinute || 60;
|
||||||
|
// Batch size / flush interval for queued records.
|
||||||
|
var batchSize = config.batchSize || 10;
|
||||||
|
var flushIntervalMs = config.flushIntervalMs || 5000;
|
||||||
|
|
||||||
|
var breadcrumbs: Breadcrumb[] = [];
|
||||||
|
// Best-effort correlation: the most recently generated trace id is
|
||||||
|
// attached to the next error report, so an error surfacing shortly
|
||||||
|
// after a failed request can usually be found alongside it. This is
|
||||||
|
// not a real per-async-context propagation mechanism.
|
||||||
|
var lastTraceId = '';
|
||||||
|
|
||||||
|
var sessionId = hasDetailedDiagnostics() ? randomHex(16) : '';
|
||||||
|
|
||||||
|
// --- Client-side queue, batching and rate limiting -----------------
|
||||||
|
|
||||||
|
var queue: LogRecord[] = [];
|
||||||
|
var flushTimer: number | null = null;
|
||||||
|
var sentTimestamps: number[] = [];
|
||||||
|
var eventsDropped = 0;
|
||||||
|
|
||||||
|
function setConsent(next: Consent) {
|
||||||
|
var normalized: Consent = next === 'granted' ? 'granted' : 'denied';
|
||||||
|
var disableDev = normalized === 'denied' && devMode;
|
||||||
|
if (consent === normalized && !disableDev) return;
|
||||||
|
if (disableDev) devMode = false;
|
||||||
|
consent = normalized;
|
||||||
|
queue = [];
|
||||||
|
if (flushTimer) global.clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
breadcrumbs = [];
|
||||||
|
contextAttrs = {};
|
||||||
|
lastTraceId = '';
|
||||||
|
sessionId = hasDetailedDiagnostics() ? randomHex(16) : '';
|
||||||
|
consentListeners.forEach(function (reset) { reset(); });
|
||||||
|
startVitals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConsent(): Consent {
|
||||||
|
return consent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDetailedDiagnostics() {
|
||||||
|
return consent === 'granted' || devMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrivacyMode(): 'counts' | 'minimal' | 'consented' | 'development' {
|
||||||
|
if (devMode) return 'development';
|
||||||
|
if (consent === 'granted') return 'consented';
|
||||||
|
return minimalDiagnostics ? 'minimal' : 'counts';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mayCaptureVitals() {
|
||||||
|
return captureWebVitals && (hasDetailedDiagnostics() || captureWebVitalsWithoutConsent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVitals() {
|
||||||
|
if (vitalsStarted || !mayCaptureVitals()) return;
|
||||||
|
vitalsStarted = true;
|
||||||
|
observeVitals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function underRateLimit() {
|
||||||
|
var now = Date.now();
|
||||||
|
// Drop timestamps older than the rolling 60s window.
|
||||||
|
while (sentTimestamps.length && now - sentTimestamps[0] > 60000) {
|
||||||
|
sentTimestamps.shift();
|
||||||
|
}
|
||||||
|
if (sentTimestamps.length >= maxEventsPerMinute) return false;
|
||||||
|
sentTimestamps.push(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(record: LogRecord) {
|
||||||
|
queue.push(record);
|
||||||
|
if (queue.length >= batchSize) {
|
||||||
|
flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!flushTimer) {
|
||||||
|
flushTimer = global.setTimeout(flush, flushIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (flushTimer) {
|
||||||
|
global.clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
if (!queue.length) return;
|
||||||
|
var records = queue.splice(0, queue.length);
|
||||||
|
ship(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ship(records: LogRecord[]) {
|
||||||
|
var payload = {
|
||||||
|
resourceLogs: [
|
||||||
|
{
|
||||||
|
resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) },
|
||||||
|
scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: records }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var body = JSON.stringify(payload);
|
||||||
|
var url = endpoint;
|
||||||
|
if (ingestKey) {
|
||||||
|
url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use uninstrumented rawFetch with keepalive: true and mode: 'cors'
|
||||||
|
// to avoid intercepting and looping on our own telemetry requests.
|
||||||
|
var fetchFn = rawFetch || (typeof global.fetch === 'function' ? global.fetch : null);
|
||||||
|
if (fetchFn) {
|
||||||
|
var fetchHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (ingestKey) {
|
||||||
|
fetchHeaders['X-Otel-Ingest-Key'] = ingestKey;
|
||||||
|
}
|
||||||
|
fetchFn.call(global, url, {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
headers: fetchHeaders,
|
||||||
|
body: body,
|
||||||
|
keepalive: true
|
||||||
|
}).catch(function () {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var severityNumbers: Record<string, number> = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17, FATAL: 21 };
|
||||||
|
|
||||||
|
function buildRecord(severity: string, message: string, attrs?: Attributes, traceId?: string) {
|
||||||
|
if (!hasDetailedDiagnostics()) {
|
||||||
|
var anonymousAttrs: Attributes = {
|
||||||
|
source: 'frontend',
|
||||||
|
'service.release': release,
|
||||||
|
'service.environment': environment,
|
||||||
|
'privacy.mode': 'counts'
|
||||||
|
};
|
||||||
|
var errorType = attrs && attrs['exception.type'];
|
||||||
|
if (typeof errorType === 'string' && ['Error', 'TypeError', 'ReferenceError', 'SyntaxError', 'RangeError', 'URIError', 'EvalError', 'UnhandledRejection', 'HttpFailure', 'ResourceLoadError'].indexOf(errorType) !== -1) {
|
||||||
|
anonymousAttrs['exception.type'] = errorType;
|
||||||
|
}
|
||||||
|
var status = attrs && String(attrs['http.response.status_code'] || '');
|
||||||
|
if (status && /^[1-5][0-9]{2}$/.test(status)) anonymousAttrs['http.response.status_code'] = status;
|
||||||
|
var diagnosticMessage = 'Frontend event';
|
||||||
|
if (minimalDiagnostics) {
|
||||||
|
try {
|
||||||
|
var diagnostic = minimalDiagnostics.sanitize({
|
||||||
|
message: String(message).slice(0, 2000),
|
||||||
|
type: typeof errorType === 'string' ? errorType.slice(0, 100) : undefined,
|
||||||
|
stacktrace: attrs && typeof attrs['exception.stacktrace'] === 'string' ? attrs['exception.stacktrace'].slice(0, 10000) : undefined
|
||||||
|
});
|
||||||
|
if (diagnostic && typeof diagnostic.message === 'string' && diagnostic.message.trim()) {
|
||||||
|
diagnosticMessage = diagnostic.message.slice(0, 2000);
|
||||||
|
anonymousAttrs['privacy.mode'] = 'minimal';
|
||||||
|
anonymousAttrs['exception.message'] = diagnosticMessage;
|
||||||
|
if (typeof diagnostic.code === 'string') anonymousAttrs['error.code'] = diagnostic.code.slice(0, 100);
|
||||||
|
if (typeof diagnostic.stacktrace === 'string') anonymousAttrs['exception.stacktrace'] = diagnostic.stacktrace.slice(0, 10000);
|
||||||
|
if (minimalDiagnostics.route) anonymousAttrs['http.route'] = minimalDiagnostics.route.slice(0, 300);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
timeUnixNano: String(Date.now() * 1e6),
|
||||||
|
severityText: Object.prototype.hasOwnProperty.call(severityNumbers, severity) ? severity : 'ERROR',
|
||||||
|
severityNumber: severityNumbers[severity] || 17,
|
||||||
|
body: { stringValue: diagnosticMessage },
|
||||||
|
attributes: toOtlpAttributes(anonymousAttrs)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var record: LogRecord = {
|
||||||
|
timeUnixNano: String(Date.now() * 1e6),
|
||||||
|
severityText: severity,
|
||||||
|
severityNumber: severityNumbers[severity] || 17,
|
||||||
|
body: { stringValue: String(message).slice(0, 2000) },
|
||||||
|
attributes: toOtlpAttributes(
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
contextAttrs,
|
||||||
|
{
|
||||||
|
source: 'frontend',
|
||||||
|
'privacy.mode': getPrivacyMode(),
|
||||||
|
'browser.url': global.location ? global.location.href : '',
|
||||||
|
'browser.userAgent': global.navigator ? global.navigator.userAgent : '',
|
||||||
|
'browser.viewport': global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
|
||||||
|
'session.id': sessionId,
|
||||||
|
'service.release': release,
|
||||||
|
'service.environment': environment,
|
||||||
|
breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined
|
||||||
|
},
|
||||||
|
attrs || {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if (traceId) {
|
||||||
|
record.traceId = traceId;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(severity: string, message: string, attrs?: Attributes, traceId?: string) {
|
||||||
|
if (sampleRate < 1 && Math.random() > sampleRate) return;
|
||||||
|
if (!underRateLimit()) {
|
||||||
|
eventsDropped++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enqueue(buildRecord(severity, message, attrs, traceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Vitals: measured by the vendored `web-vitals` library (attribution
|
||||||
|
// build, loaded lazily so it never blocks the page) and reported as ONE
|
||||||
|
// beacon per page view to /v1/vitals when the page is hidden. The
|
||||||
|
// beacon carries every metric with its attribution (LCP element, INP
|
||||||
|
// target/breakdown, CLS shift source...). If the page is hidden again
|
||||||
|
// later (tab switch) and a metric changed - typically INP - a fuller
|
||||||
|
// snapshot with the same pageView id is sent; the newest one wins.
|
||||||
|
function observeVitals() {
|
||||||
|
var pageViewId = randomHex(16);
|
||||||
|
var vitals: Record<string, { value: unknown; delta: unknown; id: string; attribution: Attributes }> = {};
|
||||||
|
var navType = '';
|
||||||
|
var dirty = false;
|
||||||
|
var sampled = sampleRate >= 1 || Math.random() <= sampleRate;
|
||||||
|
consentListeners.push(function () {
|
||||||
|
pageViewId = randomHex(16);
|
||||||
|
vitals = {};
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
var picks: Record<string, string[]> = {
|
||||||
|
LCP: ['target', 'url', 'timeToFirstByte', 'resourceLoadDelay', 'resourceLoadDuration', 'elementRenderDelay'],
|
||||||
|
INP: ['interactionTarget', 'interactionType', 'inputDelay', 'processingDuration', 'presentationDelay', 'loadState', 'totalScriptDuration', 'totalStyleAndLayoutDuration', 'totalPaintDuration'],
|
||||||
|
CLS: ['largestShiftTarget', 'largestShiftValue', 'largestShiftTime', 'loadState'],
|
||||||
|
FCP: ['timeToFirstByte', 'firstByteToFCP', 'loadState'],
|
||||||
|
TTFB: ['waitingDuration', 'cacheDuration', 'dnsDuration', 'connectionDuration', 'requestDuration']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Values keep 3 decimals: CLS is a tiny fraction and must not be rounded to an integer.
|
||||||
|
function round(n: unknown) {
|
||||||
|
return typeof n === 'number' ? Math.round(n * 1000) / 1000 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slim(metric: VitalMetric) {
|
||||||
|
var out: Attributes = {};
|
||||||
|
var src = metric.attribution || {};
|
||||||
|
(picks[metric.name] || []).forEach(function (k) {
|
||||||
|
if (src[k] !== undefined && src[k] !== null) out[k] = round(src[k]);
|
||||||
|
});
|
||||||
|
var script = src.longestScript && src.longestScript.entry;
|
||||||
|
if (script && script.sourceURL) {
|
||||||
|
out.longestScriptUrl = script.sourceURL;
|
||||||
|
if (script.sourceFunctionName) out.longestScriptFunction = script.sourceFunctionName;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMetric(metric: VitalMetric) {
|
||||||
|
if (!mayCaptureVitals()) return;
|
||||||
|
vitals[metric.name] = { value: round(metric.value), delta: round(metric.delta), id: metric.id, attribution: slim(metric) };
|
||||||
|
navType = metric.navigationType || navType;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendVitals() {
|
||||||
|
if (!mayCaptureVitals() || !dirty || !sampled) return;
|
||||||
|
dirty = false;
|
||||||
|
var granted = hasDetailedDiagnostics();
|
||||||
|
var anonymousMetrics: Record<string, { value: unknown; delta: unknown; attribution: Attributes }> = {};
|
||||||
|
Object.keys(vitals).forEach(function (name) {
|
||||||
|
var attribution: Attributes = {};
|
||||||
|
Object.keys(vitals[name].attribution).forEach(function (key) {
|
||||||
|
var value = vitals[name].attribution[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) attribution[key] = value;
|
||||||
|
});
|
||||||
|
anonymousMetrics[name] = { value: vitals[name].value, delta: vitals[name].delta, attribution: attribution };
|
||||||
|
});
|
||||||
|
var body = JSON.stringify({
|
||||||
|
service: serviceName,
|
||||||
|
environment: environment,
|
||||||
|
release: release,
|
||||||
|
session: sessionId,
|
||||||
|
pageView: pageViewId,
|
||||||
|
url: granted && global.location ? global.location.href : '',
|
||||||
|
ua: granted && global.navigator ? global.navigator.userAgent : '',
|
||||||
|
viewport: granted && global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
|
||||||
|
nav: navType,
|
||||||
|
conn: granted && (global.navigator && global.navigator.connection && global.navigator.connection.effectiveType) || '',
|
||||||
|
metrics: granted ? vitals : anonymousMetrics
|
||||||
|
});
|
||||||
|
var url = vitalsEndpoint;
|
||||||
|
if (ingestKey) url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
|
||||||
|
if (rawFetch) {
|
||||||
|
rawFetch.call(global, url, { method: 'POST', mode: 'cors', credentials: 'omit', referrerPolicy: 'no-referrer', headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, body: body, keepalive: true }).catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var script = global.document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.referrerPolicy = 'no-referrer';
|
||||||
|
script.src = vitalsLibUrl;
|
||||||
|
script.onload = function () {
|
||||||
|
if (!mayCaptureVitals()) {
|
||||||
|
vitalsStarted = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var wv = global.webVitals;
|
||||||
|
if (!wv) return;
|
||||||
|
// INP durationThreshold stays at the library default; one listener per metric.
|
||||||
|
[wv.onLCP, wv.onINP, wv.onCLS, wv.onFCP, wv.onTTFB].forEach(function (observe) {
|
||||||
|
if (typeof observe === 'function') observe(onMetric);
|
||||||
|
});
|
||||||
|
// Registered after the library's own hidden listeners, so the
|
||||||
|
// final LCP/INP/CLS values are already collected when this runs.
|
||||||
|
global.addEventListener('visibilitychange', function () {
|
||||||
|
if (global.document.visibilityState === 'hidden') sendVitals();
|
||||||
|
});
|
||||||
|
global.addEventListener('pagehide', sendVitals);
|
||||||
|
// bfcache restore is a new page view for vitals purposes.
|
||||||
|
global.addEventListener('pageshow', function (e) {
|
||||||
|
if (e.persisted) {
|
||||||
|
pageViewId = randomHex(16);
|
||||||
|
vitals = {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
global.document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTodoWidget() {
|
||||||
|
var todo = config.todo || {};
|
||||||
|
function start() {
|
||||||
|
if (!global.OtelTodo) return;
|
||||||
|
global.OtelTodo.init({
|
||||||
|
getConsent: getConsent,
|
||||||
|
getSessionId: function () { return sessionId; },
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
serviceName: serviceName,
|
||||||
|
ingestKey: ingestKey,
|
||||||
|
components: todo.components || ((scriptEl && scriptEl.getAttribute('data-components')) || '').split(','),
|
||||||
|
shortcut: todo.shortcut || (scriptEl && scriptEl.getAttribute('data-shortcut')) || '',
|
||||||
|
hideButton: todo.hideButton === true || !!(scriptEl && scriptEl.getAttribute('data-hide-button') === 'true')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (global.OtelTodo) return start();
|
||||||
|
var s = global.document.createElement('script');
|
||||||
|
s.async = true;
|
||||||
|
s.referrerPolicy = 'no-referrer';
|
||||||
|
s.src = todoLibUrl;
|
||||||
|
s.onload = start;
|
||||||
|
global.document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
if (enableTodo && !excludeTodo && global.document) {
|
||||||
|
function scheduleTodoWidget() {
|
||||||
|
if (typeof global.requestIdleCallback === 'function') {
|
||||||
|
global.requestIdleCallback(loadTodoWidget);
|
||||||
|
} else {
|
||||||
|
global.setTimeout(loadTodoWidget, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (global.document.readyState === 'complete') scheduleTodoWidget();
|
||||||
|
else global.addEventListener('load', scheduleTodoWidget, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// traceHeaders returns a headers object carrying a fresh (or
|
||||||
|
// continued) W3C traceparent, ready to spread into any HTTP client's
|
||||||
|
// request headers - fetch, XHR, axios, etc.
|
||||||
|
function traceHeaders(existingTraceId?: string) {
|
||||||
|
var traceId = existingTraceId || newTraceId();
|
||||||
|
var spanId = newSpanId();
|
||||||
|
lastTraceId = traceId;
|
||||||
|
return { traceparent: '00-' + traceId + '-' + spanId + '-01' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureException(err: unknown, extra?: Attributes) {
|
||||||
|
var exception = err && typeof err === 'object' ? err as ExceptionLike : undefined;
|
||||||
|
var message = (exception && exception.message) || String(err);
|
||||||
|
send(
|
||||||
|
'ERROR',
|
||||||
|
message,
|
||||||
|
Object.assign(
|
||||||
|
{
|
||||||
|
'exception.type': (exception && exception.name) || 'Error',
|
||||||
|
'exception.message': message,
|
||||||
|
'exception.stacktrace': (exception && exception.stack) || ''
|
||||||
|
},
|
||||||
|
extra || {}
|
||||||
|
),
|
||||||
|
lastTraceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureMessage(message: string, severity?: string, extra?: Attributes) {
|
||||||
|
send(severity || 'ERROR', message, extra, lastTraceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContext(context: Attributes) {
|
||||||
|
if (!hasDetailedDiagnostics()) return;
|
||||||
|
// Attach stable context (e.g. { user: 'u-123', plan: 'pro' }) to
|
||||||
|
// every subsequent event. Values become OTLP attributes prefixed
|
||||||
|
// with "context.".
|
||||||
|
contextAttrs = Object.assign({}, contextAttrs, context || {});
|
||||||
|
flush(); // ensure earlier queued events don't mix contexts ambiguously
|
||||||
|
}
|
||||||
|
var contextAttrs: Attributes = {};
|
||||||
|
|
||||||
|
function onError(event: Event) {
|
||||||
|
// Resource load errors (img/script/link) surface as an ErrorEvent-less
|
||||||
|
// Event targeting the failed element; script errors carry a message.
|
||||||
|
var target = event.target as HTMLElement | null;
|
||||||
|
if (target && event.target !== global && target.tagName) {
|
||||||
|
var tag = String(target.tagName).toLowerCase();
|
||||||
|
if (tag === 'img' || tag === 'script' || tag === 'link') {
|
||||||
|
var url = (target as HTMLImageElement).src || (target as HTMLLinkElement).href || '';
|
||||||
|
captureMessage('Failed to load ' + tag + ': ' + url, 'ERROR', {
|
||||||
|
'exception.type': 'ResourceLoadError',
|
||||||
|
'resource.tag': tag,
|
||||||
|
'resource.url': url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorEvent = event as ErrorEvent;
|
||||||
|
var message = errorEvent.message || 'Script error';
|
||||||
|
captureException(errorEvent.error || { name: 'Error', message: message }, {
|
||||||
|
'source.file': errorEvent.filename || '',
|
||||||
|
'source.line': errorEvent.lineno,
|
||||||
|
'source.column': errorEvent.colno
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUnhandledRejection(event: PromiseRejectionEvent) {
|
||||||
|
var reason = event.reason;
|
||||||
|
captureException(reason instanceof Error ? reason : { name: 'UnhandledRejection', message: String(reason) }, {
|
||||||
|
'exception.type': (reason && reason.name) || 'UnhandledRejection'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.addEventListener('error', onError);
|
||||||
|
// Resource load errors don't bubble - must listen on the capturing phase.
|
||||||
|
global.addEventListener('error', onError, true);
|
||||||
|
global.addEventListener('unhandledrejection', onUnhandledRejection);
|
||||||
|
|
||||||
|
if (captureConsoleErrors && global.console && typeof global.console.error === 'function') {
|
||||||
|
var originalConsoleError = global.console.error;
|
||||||
|
global.console.error = function (...args: unknown[]) {
|
||||||
|
try {
|
||||||
|
addBreadcrumb('console.error', args.join(' '));
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return originalConsoleError.apply(global.console, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldInject(url: string) {
|
||||||
|
if (!injectTraceHeaders) return false;
|
||||||
|
if (!traceHeaderOrigins) return isSameOrigin(url);
|
||||||
|
return traceHeaderOrigins.some(function (o) {
|
||||||
|
return url.indexOf(o) === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpFailureReport reports a failed HTTP round-trip as an ERROR event,
|
||||||
|
// so backend 5xx/4xx responses show up in the Issues view even when
|
||||||
|
// the app code swallows them.
|
||||||
|
function httpFailureReport(kind: string, method: string, url: string, status?: number, traceId?: string, extra?: Attributes) {
|
||||||
|
// Issue refresh failures must not create new issues notifications:
|
||||||
|
// otherwise an expired session can form an API-error feedback loop.
|
||||||
|
try {
|
||||||
|
if (new global.URL(url, global.location.href).pathname === '/api/issues') return;
|
||||||
|
} catch (e) {}
|
||||||
|
var attrs = Object.assign(
|
||||||
|
{
|
||||||
|
'exception.type': 'HttpFailure',
|
||||||
|
'http.request.method': method,
|
||||||
|
'url.full': String(url).slice(0, 500),
|
||||||
|
'http.response.status_code': status === undefined || status === 0 ? '' : String(status)
|
||||||
|
},
|
||||||
|
extra || {}
|
||||||
|
);
|
||||||
|
captureMessage(
|
||||||
|
method + ' ' + url + ' failed with ' + (status === undefined || status === 0 ? 'network error' : 'HTTP ' + status),
|
||||||
|
'ERROR',
|
||||||
|
attrs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoInstrumentFetch && typeof global.fetch === 'function') {
|
||||||
|
var originalFetch = global.fetch;
|
||||||
|
global.fetch = function (input, requestInit) {
|
||||||
|
var request = typeof input !== 'string' && 'url' in input ? input : undefined;
|
||||||
|
var url = typeof input === 'string' ? input : request ? request.url : String(input);
|
||||||
|
if (isCollectorUrl(url)) {
|
||||||
|
return originalFetch.call(global, input, requestInit);
|
||||||
|
}
|
||||||
|
var method = (requestInit && requestInit.method) || (request && request.method) || 'GET';
|
||||||
|
var requestTraceId = '';
|
||||||
|
|
||||||
|
if (shouldInject(url)) {
|
||||||
|
requestInit = requestInit || {};
|
||||||
|
var headers = new global.Headers(requestInit.headers || (request && request.headers) || {});
|
||||||
|
var th = traceHeaders();
|
||||||
|
headers.set('traceparent', th.traceparent);
|
||||||
|
requestTraceId = th.traceparent.slice(3, 35); // trace id portion
|
||||||
|
requestInit = Object.assign({}, requestInit, { headers: headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
var startedAt = Date.now();
|
||||||
|
return originalFetch.call(global, input, requestInit).then(
|
||||||
|
function (res) {
|
||||||
|
addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status, { d: Date.now() - startedAt, tid: requestTraceId });
|
||||||
|
if (reportHttpFailures && res.status >= 400) {
|
||||||
|
httpFailureReport('fetch', method, url, res.status, requestTraceId);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
function (err) {
|
||||||
|
addBreadcrumb('fetch', method + ' ' + url + ' -> error', { d: Date.now() - startedAt, tid: requestTraceId });
|
||||||
|
if (reportHttpFailures) {
|
||||||
|
httpFailureReport('fetch', method, url, 0, requestTraceId);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// XHR instrumentation: trace-header injection + breadcrumbs + failure
|
||||||
|
// reporting, so legacy code paths (axios older versions, jQuery, etc.)
|
||||||
|
// get the same visibility as fetch.
|
||||||
|
if (autoInstrumentXhr && typeof global.XMLHttpRequest === 'function') {
|
||||||
|
var originalOpen = global.XMLHttpRequest.prototype.open;
|
||||||
|
var originalSend = global.XMLHttpRequest.prototype.send;
|
||||||
|
var originalSetRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader;
|
||||||
|
|
||||||
|
global.XMLHttpRequest.prototype.open = function (method, url) {
|
||||||
|
if (isCollectorUrl(url)) {
|
||||||
|
return originalOpen.apply(this, arguments as unknown as Parameters<XMLHttpRequest['open']>);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.__rum = { method: String(method), url: String(url) };
|
||||||
|
if (shouldInject(String(url))) {
|
||||||
|
var th = traceHeaders();
|
||||||
|
this.__rum.traceId = th.traceparent.slice(3, 35);
|
||||||
|
originalSetRequestHeader.call(this, 'traceparent', th.traceparent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return originalOpen.apply(this, arguments as unknown as Parameters<XMLHttpRequest['open']>);
|
||||||
|
};
|
||||||
|
|
||||||
|
global.XMLHttpRequest.prototype.send = function () {
|
||||||
|
var xhr = this;
|
||||||
|
const info = xhr.__rum;
|
||||||
|
var xhrStartedAt = Date.now();
|
||||||
|
if (info && info.url) {
|
||||||
|
var origHandler = xhr.onreadystatechange;
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
try {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status, { d: Date.now() - xhrStartedAt, tid: info.traceId });
|
||||||
|
if (reportHttpFailures && (xhr.status === 0 || xhr.status >= 400)) {
|
||||||
|
httpFailureReport('xhr', info.method, info.url, xhr.status, info.traceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (origHandler) return origHandler.apply(this, arguments as unknown as [Event]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalSend.apply(this, arguments as unknown as Parameters<XMLHttpRequest['send']>);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA route-change breadcrumbs: history.pushState/replaceState and
|
||||||
|
// popstate/hashes get recorded so an error report shows which route
|
||||||
|
// transitions preceded it.
|
||||||
|
if (trackSpaRoutes && global.history) {
|
||||||
|
function recordRoute(kind: string) {
|
||||||
|
addBreadcrumb('navigation', kind + ' ' + (global.location ? global.location.pathname + global.location.search : ''));
|
||||||
|
}
|
||||||
|
var originalPush = global.history.pushState;
|
||||||
|
var originalReplace = global.history.replaceState;
|
||||||
|
if (typeof originalPush === 'function') {
|
||||||
|
global.history.pushState = function () {
|
||||||
|
var result = originalPush.apply(this, arguments as unknown as Parameters<History['pushState']>);
|
||||||
|
try {
|
||||||
|
recordRoute('pushState');
|
||||||
|
} catch (e) {}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof originalReplace === 'function') {
|
||||||
|
global.history.replaceState = function () {
|
||||||
|
var result = originalReplace.apply(this, arguments as unknown as Parameters<History['replaceState']>);
|
||||||
|
try {
|
||||||
|
recordRoute('replaceState');
|
||||||
|
} catch (e) {}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
global.addEventListener('popstate', function () {
|
||||||
|
try {
|
||||||
|
recordRoute('popstate');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush pending events when the page is being hidden or unloaded so
|
||||||
|
// errors that happen right before navigation are not lost, and emit
|
||||||
|
// the final CLS value.
|
||||||
|
global.addEventListener('pagehide', function () {
|
||||||
|
flush();
|
||||||
|
});
|
||||||
|
global.addEventListener('visibilitychange', function () {
|
||||||
|
if (global.document && global.document.visibilityState === 'hidden') {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// First paint: report vitals after observers have a chance to buffer.
|
||||||
|
startVitals();
|
||||||
|
|
||||||
|
// extra.d = request duration in ms, extra.tid = trace id we sent on the
|
||||||
|
// request. t stays the completion time; the request started at t - d.
|
||||||
|
function addBreadcrumb(category: string, message: string, extra?: { d?: number; tid?: string }) {
|
||||||
|
if (!hasDetailedDiagnostics()) return;
|
||||||
|
var crumb: Breadcrumb = { t: Date.now(), category: String(category), message: String(message) };
|
||||||
|
if (extra && typeof extra.d === 'number') crumb.d = extra.d;
|
||||||
|
if (extra && extra.tid) crumb.tid = String(extra.tid);
|
||||||
|
breadcrumbs.push(crumb);
|
||||||
|
if (breadcrumbs.length > maxBreadcrumbs) {
|
||||||
|
breadcrumbs.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = {
|
||||||
|
setConsent: setConsent,
|
||||||
|
getConsent: getConsent,
|
||||||
|
getPrivacyMode: getPrivacyMode,
|
||||||
|
captureException: captureException,
|
||||||
|
captureMessage: captureMessage,
|
||||||
|
addBreadcrumb: addBreadcrumb,
|
||||||
|
setContext: setContext,
|
||||||
|
flush: flush,
|
||||||
|
newTraceId: newTraceId,
|
||||||
|
newSpanId: newSpanId,
|
||||||
|
traceHeaders: traceHeaders
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convenience handle for manual/console verification:
|
||||||
|
// OtelRUM.last.captureMessage('test from console').
|
||||||
|
global.OtelRUM.last = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId, last: undefined };
|
||||||
|
|
||||||
|
// Auto-initialize if data attributes are declared on the <script> element
|
||||||
|
try {
|
||||||
|
if (typeof document !== 'undefined' && scriptEl && (scriptEl.getAttribute('data-auto-init') === 'true' || scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
global.OtelRUM.init();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
global.OtelRUM.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})((typeof window !== 'undefined' ? window : globalThis) as BrowserGlobal);
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* otel-todo.js - file todos / issues straight from the page.
|
||||||
|
*
|
||||||
|
* Shows a small floating symbol and listens for a keyboard shortcut. Opens a
|
||||||
|
* native <dialog> in the bottom-right corner with a text box, an optional
|
||||||
|
* component picker, and an "Reference an element" toggle that lets you click
|
||||||
|
* any node on the page (with a hover outline) to attach its CSS selector and
|
||||||
|
* a short label to the note. POSTs the note (with the current path, hash and
|
||||||
|
* any picked element) to the collector's /v1/todos endpoint.
|
||||||
|
*
|
||||||
|
* <script src="https://otel.example.com/static/otel-todo.js"
|
||||||
|
* data-service-name="checkout-web"
|
||||||
|
* data-ingest-key="..."
|
||||||
|
* data-components="Header,Cart,Payment"></script>
|
||||||
|
*
|
||||||
|
* Attributes (or OtelTodo.init({...}) keys): data-base-url, data-service-name
|
||||||
|
* (the project), data-ingest-key, data-components (comma list, also
|
||||||
|
* discovered from [data-component] elements on the page), data-shortcut
|
||||||
|
* (default "alt+shift+t"), data-todo-endpoint, data-hide-button="true".
|
||||||
|
*/
|
||||||
|
import type { BrowserGlobal, Consent, TodoConfig, TodoInstance } from './types';
|
||||||
|
|
||||||
|
(function (global: BrowserGlobal) {
|
||||||
|
'use strict';
|
||||||
|
var DEFAULT_BASE_URL = 'https://otel.k6n.net';
|
||||||
|
var scriptEl = document.currentScript;
|
||||||
|
|
||||||
|
function attr(name: string) {
|
||||||
|
return (scriptEl && scriptEl.getAttribute(name)) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShortcut(s: string) {
|
||||||
|
var parts = String(s).toLowerCase().split('+');
|
||||||
|
var sc = { key: parts.pop(), alt: false, shift: false, ctrl: false, meta: false };
|
||||||
|
parts.forEach(function (p) {
|
||||||
|
if (p === 'alt' || p === 'option') sc.alt = true;
|
||||||
|
else if (p === 'shift') sc.shift = true;
|
||||||
|
else if (p === 'ctrl' || p === 'control') sc.ctrl = true;
|
||||||
|
else if (p === 'meta' || p === 'cmd') sc.meta = true;
|
||||||
|
});
|
||||||
|
return sc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(config: TodoConfig = {}): TodoInstance {
|
||||||
|
config = config || {};
|
||||||
|
if (global.OtelTodo && global.OtelTodo.instance) return global.OtelTodo.instance;
|
||||||
|
|
||||||
|
var baseUrl = (config.baseUrl || attr('data-base-url') || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||||
|
var endpoint = config.endpoint || attr('data-todo-endpoint') || baseUrl + '/v1/todos';
|
||||||
|
var project = config.serviceName || attr('data-service-name') || attr('data-service') || 'frontend';
|
||||||
|
var ingestKey = config.ingestKey || attr('data-ingest-key');
|
||||||
|
var shortcut = parseShortcut(config.shortcut || attr('data-shortcut') || 'alt+shift+t');
|
||||||
|
var hideButton = config.hideButton || attr('data-hide-button') === 'true';
|
||||||
|
var staticComponents = config.components || attr('data-components').split(',');
|
||||||
|
staticComponents = staticComponents.map(function (c) { return c.trim(); }).filter(Boolean);
|
||||||
|
|
||||||
|
var consent: Consent = (config.consent || attr('data-consent')) === 'granted' ? 'granted' : 'denied';
|
||||||
|
function getConsent(): Consent {
|
||||||
|
return config.getConsent ? config.getConsent() : consent;
|
||||||
|
}
|
||||||
|
function setConsent(next: Consent) {
|
||||||
|
consent = next === 'granted' ? 'granted' : 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
var css = document.createElement('style');
|
||||||
|
css.textContent =
|
||||||
|
'.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;' +
|
||||||
|
'border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}' +
|
||||||
|
'.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}' +
|
||||||
|
'dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;' +
|
||||||
|
'border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}' +
|
||||||
|
'dialog.otel-todo *{box-sizing:border-box;font:inherit}' +
|
||||||
|
'dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}' +
|
||||||
|
'dialog.otel-todo textarea{min-height:90px;resize:vertical}' +
|
||||||
|
'dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}' +
|
||||||
|
'dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
||||||
|
'dialog.otel-todo .status{margin-right:auto;font-size:12px}' +
|
||||||
|
'.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;' +
|
||||||
|
'background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}' +
|
||||||
|
'.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}' +
|
||||||
|
'.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}' +
|
||||||
|
'.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;' +
|
||||||
|
'border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}' +
|
||||||
|
'.otel-todo-ref.show{display:flex}' +
|
||||||
|
'.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
||||||
|
'.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}' +
|
||||||
|
'.otel-todo-ref button:hover{opacity:1}' +
|
||||||
|
'.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;' +
|
||||||
|
'background:#3b82f626;border-radius:3px;transition:all .05s linear}' +
|
||||||
|
'.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;' +
|
||||||
|
'background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;' +
|
||||||
|
'box-shadow:0 4px 14px #0004}';
|
||||||
|
document.head.appendChild(css);
|
||||||
|
|
||||||
|
var dlg = document.createElement('dialog');
|
||||||
|
dlg.className = 'otel-todo';
|
||||||
|
dlg.setAttribute('aria-label', 'Add todo');
|
||||||
|
dlg.innerHTML =
|
||||||
|
'<form method="dialog">' +
|
||||||
|
'<div class="meta"></div>' +
|
||||||
|
'<button type="button" class="otel-todo-pick" aria-pressed="false">⌖ Reference an element</button>' +
|
||||||
|
'<div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">✕</button></div>' +
|
||||||
|
'<textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea>' +
|
||||||
|
'<select name="component" aria-label="Component (optional)"></select>' +
|
||||||
|
'<div class="row"><span class="status" role="status"></span>' +
|
||||||
|
'<button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div>' +
|
||||||
|
'</form>';
|
||||||
|
var form = dlg.firstElementChild as HTMLFormElement;
|
||||||
|
var textEl = form.elements.namedItem('text') as HTMLTextAreaElement;
|
||||||
|
var selectEl = form.elements.namedItem('component') as HTMLSelectElement;
|
||||||
|
var statusEl = form.querySelector<HTMLElement>('.status')!;
|
||||||
|
var metaEl = form.querySelector<HTMLElement>('.meta')!;
|
||||||
|
var pickBtn = form.querySelector<HTMLButtonElement>('.otel-todo-pick')!;
|
||||||
|
var refEl = form.querySelector<HTMLElement>('.otel-todo-ref')!;
|
||||||
|
var refLabelEl = refEl.querySelector('span')!;
|
||||||
|
document.body.appendChild(dlg);
|
||||||
|
|
||||||
|
// --- element picker: click a node on the page to attach its selector ---
|
||||||
|
var picked: { selector: string; label: string } | null = null;
|
||||||
|
var hoverBox: HTMLDivElement | null = null;
|
||||||
|
var picking = false;
|
||||||
|
var hint: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
function cssPath(el: Element) {
|
||||||
|
if (!(el instanceof Element)) return '';
|
||||||
|
var parts = [];
|
||||||
|
var node: Element | null = el;
|
||||||
|
while (node && node.nodeType === 1 && parts.length < 6) {
|
||||||
|
if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
|
||||||
|
var part = node.tagName.toLowerCase();
|
||||||
|
var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
||||||
|
if (cls.length) part += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.');
|
||||||
|
var parent: HTMLElement | null = node.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
var tagName = node.tagName;
|
||||||
|
var siblings = Array.from(parent.children).filter(function (child) { return child.tagName === tagName; });
|
||||||
|
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
||||||
|
}
|
||||||
|
parts.unshift(part);
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
return parts.join(' > ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeElement(el: Element) {
|
||||||
|
var tag = el.tagName.toLowerCase();
|
||||||
|
var text = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (text.length > 60) text = text.slice(0, 60) + '…';
|
||||||
|
return { selector: cssPath(el), label: text ? tag + ' – "' + text + '"' : tag };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRef(ref: { selector: string; label: string } | null) {
|
||||||
|
picked = ref;
|
||||||
|
if (ref) {
|
||||||
|
refLabelEl.textContent = ref.label;
|
||||||
|
refLabelEl.title = ref.selector;
|
||||||
|
refEl.classList.add('show');
|
||||||
|
} else {
|
||||||
|
refEl.classList.remove('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHoverBox() {
|
||||||
|
if (!hoverBox) {
|
||||||
|
hoverBox = document.createElement('div');
|
||||||
|
hoverBox.className = 'otel-todo-hover-outline';
|
||||||
|
document.body.appendChild(hoverBox);
|
||||||
|
}
|
||||||
|
return hoverBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickMove(e: MouseEvent) {
|
||||||
|
var el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
if (!el || dlg.contains(el)) { ensureHoverBox().style.display = 'none'; return; }
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
var box = ensureHoverBox();
|
||||||
|
box.style.display = 'block';
|
||||||
|
box.style.left = r.left + 'px';
|
||||||
|
box.style.top = r.top + 'px';
|
||||||
|
box.style.width = r.width + 'px';
|
||||||
|
box.style.height = r.height + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickClick(e: MouseEvent) {
|
||||||
|
var el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
if (!el || dlg.contains(el)) return; // let dialog controls work normally
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setRef(describeElement(el));
|
||||||
|
stopPicking();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPicking(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPicking() {
|
||||||
|
if (picking) return;
|
||||||
|
picking = true;
|
||||||
|
pickBtn.setAttribute('aria-pressed', 'true');
|
||||||
|
pickBtn.textContent = '⌖ Click an element… (Esc to cancel)';
|
||||||
|
hint = document.createElement('div');
|
||||||
|
hint.className = 'otel-todo-pick-hint';
|
||||||
|
hint.textContent = 'Click the element you mean — Esc to cancel';
|
||||||
|
document.body.appendChild(hint);
|
||||||
|
document.addEventListener('mousemove', onPickMove, true);
|
||||||
|
document.addEventListener('click', onPickClick, true);
|
||||||
|
document.addEventListener('keydown', onPickKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPicking() {
|
||||||
|
if (!picking) return;
|
||||||
|
picking = false;
|
||||||
|
pickBtn.setAttribute('aria-pressed', 'false');
|
||||||
|
pickBtn.textContent = '⌖ Reference an element';
|
||||||
|
document.removeEventListener('mousemove', onPickMove, true);
|
||||||
|
document.removeEventListener('click', onPickClick, true);
|
||||||
|
document.removeEventListener('keydown', onPickKey, true);
|
||||||
|
if (hoverBox) { hoverBox.style.display = 'none'; }
|
||||||
|
if (hint) { hint.remove(); hint = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
pickBtn.addEventListener('click', function () { picking ? stopPicking() : startPicking(); });
|
||||||
|
refEl.querySelector('[data-clear-ref]')!.addEventListener('click', function () { setRef(null); });
|
||||||
|
|
||||||
|
function components() {
|
||||||
|
var seen: Record<string, boolean> = {};
|
||||||
|
var list: string[] = [];
|
||||||
|
staticComponents.concat(Array.from(document.querySelectorAll('[data-component]'), function (el) {
|
||||||
|
return el.getAttribute('data-component') || '';
|
||||||
|
})).forEach(function (c) {
|
||||||
|
if (c && !seen[c]) { seen[c] = true; list.push(c); }
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (dlg.open) { textEl.focus(); return; }
|
||||||
|
selectEl.innerHTML = '';
|
||||||
|
var none = document.createElement('option');
|
||||||
|
none.value = '';
|
||||||
|
none.textContent = 'No component';
|
||||||
|
selectEl.appendChild(none);
|
||||||
|
var list = components();
|
||||||
|
list.forEach(function (c) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = o.textContent = c;
|
||||||
|
selectEl.appendChild(o);
|
||||||
|
});
|
||||||
|
selectEl.hidden = list.length === 0;
|
||||||
|
metaEl.textContent = project + ' · ' + location.pathname + location.hash;
|
||||||
|
statusEl.textContent = '';
|
||||||
|
setRef(null);
|
||||||
|
// Non-modal so the page stays usable while the note is being written.
|
||||||
|
dlg.show();
|
||||||
|
textEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
stopPicking();
|
||||||
|
if (dlg.open) dlg.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
form.querySelector('[data-cancel]')!.addEventListener('click', close);
|
||||||
|
dlg.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||||
|
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
|
||||||
|
});
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var text = textEl.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
var headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
var url = endpoint;
|
||||||
|
if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey;
|
||||||
|
var granted = getConsent() === 'granted';
|
||||||
|
statusEl.textContent = 'Sending…';
|
||||||
|
global.fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
project: project,
|
||||||
|
text: text,
|
||||||
|
component: selectEl.value,
|
||||||
|
path: granted ? location.pathname : '',
|
||||||
|
hash: granted ? location.hash : '',
|
||||||
|
url: granted ? location.href : '',
|
||||||
|
ua: granted ? navigator.userAgent : '',
|
||||||
|
session: granted && config.getSessionId ? config.getSessionId() : '',
|
||||||
|
elementSelector: picked ? picked.selector : '',
|
||||||
|
elementLabel: picked ? picked.label : ''
|
||||||
|
})
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
textEl.value = '';
|
||||||
|
setRef(null);
|
||||||
|
close();
|
||||||
|
}).catch(function (err) {
|
||||||
|
statusEl.textContent = 'Failed: ' + err.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
global.addEventListener('keydown', function (e) {
|
||||||
|
var k = e.key.toLowerCase();
|
||||||
|
var code = (e.code || '').replace(/^(Key|Digit)/, '').toLowerCase();
|
||||||
|
if ((k === shortcut.key || code === shortcut.key) && e.altKey === shortcut.alt && e.shiftKey === shortcut.shift &&
|
||||||
|
e.ctrlKey === shortcut.ctrl && e.metaKey === shortcut.meta) {
|
||||||
|
e.preventDefault();
|
||||||
|
dlg.open ? close() : open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hideButton) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'otel-todo-btn';
|
||||||
|
btn.textContent = '✎';
|
||||||
|
btn.title = 'Add todo (' + (config.shortcut || attr('data-shortcut') || 'alt+shift+t') + ')';
|
||||||
|
btn.setAttribute('aria-label', 'Add todo');
|
||||||
|
btn.addEventListener('click', function () { dlg.open ? close() : open(); });
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = { open: open, close: close, getConsent: getConsent, setConsent: setConsent };
|
||||||
|
global.OtelTodo!.instance = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.OtelTodo = { init: init, instance: undefined };
|
||||||
|
|
||||||
|
if (scriptEl && (attr('data-auto-init') === 'true' || attr('data-service-name') || attr('data-service'))) {
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { init(); });
|
||||||
|
else init();
|
||||||
|
}
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
export type Consent = 'granted' | 'denied';
|
||||||
|
|
||||||
|
export interface DiagnosticEvent {
|
||||||
|
message: string;
|
||||||
|
type?: string;
|
||||||
|
stacktrace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MinimalDiagnostic {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
stacktrace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MinimalDiagnosticsPolicy {
|
||||||
|
sanitize(event: DiagnosticEvent): MinimalDiagnostic | null;
|
||||||
|
route?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoConfig {
|
||||||
|
consent?: Consent;
|
||||||
|
getConsent?: () => Consent;
|
||||||
|
getSessionId?: () => string;
|
||||||
|
baseUrl?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
ingestKey?: string;
|
||||||
|
components?: string[];
|
||||||
|
shortcut?: string;
|
||||||
|
hideButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RumConfig {
|
||||||
|
consent?: Consent;
|
||||||
|
minimalDiagnostics?: MinimalDiagnosticsPolicy;
|
||||||
|
devMode?: boolean;
|
||||||
|
devModeHosts?: string[];
|
||||||
|
baseUrl?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
vitalsEndpoint?: string;
|
||||||
|
vitalsLibUrl?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
environment?: string;
|
||||||
|
release?: string;
|
||||||
|
ingestKey?: string;
|
||||||
|
sampleRate?: number;
|
||||||
|
maxBreadcrumbs?: number;
|
||||||
|
captureConsoleErrors?: boolean;
|
||||||
|
autoInstrumentFetch?: boolean;
|
||||||
|
autoInstrumentXhr?: boolean;
|
||||||
|
trackSpaRoutes?: boolean;
|
||||||
|
reportHttpFailures?: boolean;
|
||||||
|
captureWebVitals?: boolean;
|
||||||
|
captureWebVitalsWithoutConsent?: boolean;
|
||||||
|
enableTodo?: boolean;
|
||||||
|
excludeTodo?: boolean;
|
||||||
|
todoLibUrl?: string;
|
||||||
|
todo?: TodoConfig;
|
||||||
|
injectTraceHeaders?: boolean;
|
||||||
|
traceHeaderOrigins?: string[];
|
||||||
|
maxEventsPerMinute?: number;
|
||||||
|
batchSize?: number;
|
||||||
|
flushIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Attributes = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface ExceptionLike {
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RumInstance {
|
||||||
|
setConsent(consent: Consent): void;
|
||||||
|
getConsent(): Consent;
|
||||||
|
getPrivacyMode(): 'counts' | 'minimal' | 'consented' | 'development';
|
||||||
|
captureException(error: unknown, extra?: Attributes): void;
|
||||||
|
captureMessage(message: string, severity?: string, extra?: Attributes): void;
|
||||||
|
addBreadcrumb(category: string, message: string, extra?: { d?: number; tid?: string }): void;
|
||||||
|
setContext(context: Attributes): void;
|
||||||
|
flush(): void;
|
||||||
|
newTraceId(): string;
|
||||||
|
newSpanId(): string;
|
||||||
|
traceHeaders(existingTraceId?: string): { traceparent: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RumAPI {
|
||||||
|
init(config?: RumConfig): RumInstance;
|
||||||
|
newTraceId(): string;
|
||||||
|
newSpanId(): string;
|
||||||
|
last?: RumInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoInstance {
|
||||||
|
setConsent(consent: Consent): void;
|
||||||
|
getConsent(): Consent;
|
||||||
|
open(): void;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoAPI {
|
||||||
|
init(config?: TodoConfig): TodoInstance;
|
||||||
|
instance?: TodoInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VitalMetric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
delta: number;
|
||||||
|
id: string;
|
||||||
|
navigationType?: string;
|
||||||
|
attribution?: Record<string, unknown> & {
|
||||||
|
longestScript?: { entry?: { sourceURL?: string; sourceFunctionName?: string } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
OtelRUM: RumAPI;
|
||||||
|
OtelTodo?: TodoAPI;
|
||||||
|
webVitals?: Partial<Record<'onLCP' | 'onINP' | 'onCLS' | 'onFCP' | 'onTTFB', (callback: (metric: VitalMetric) => void) => void>>;
|
||||||
|
}
|
||||||
|
interface Navigator {
|
||||||
|
connection?: { effectiveType?: string };
|
||||||
|
}
|
||||||
|
interface XMLHttpRequest {
|
||||||
|
__rum?: { method: string; url: string; traceId?: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrowserGlobal = Window & typeof globalThis;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "../static"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('../static/otel-rum.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
function setup({ readyState = 'loading', attributes = {}, idle = true, existingTodo = false, fetch } = {}) {
|
||||||
|
const scripts = [];
|
||||||
|
const timers = [];
|
||||||
|
const idleCallbacks = [];
|
||||||
|
const listeners = new Map();
|
||||||
|
const initializations = [];
|
||||||
|
const window = {
|
||||||
|
document: {
|
||||||
|
currentScript: {
|
||||||
|
src: 'https://assets.example.com/otel-rum.js',
|
||||||
|
getAttribute: name => attributes[name] ?? null,
|
||||||
|
},
|
||||||
|
readyState,
|
||||||
|
body: {},
|
||||||
|
createElement: tagName => ({ tagName }),
|
||||||
|
head: { appendChild: script => scripts.push(script) },
|
||||||
|
addEventListener: (...args) => window.addEventListener(...args),
|
||||||
|
},
|
||||||
|
fetch,
|
||||||
|
Headers,
|
||||||
|
URL,
|
||||||
|
addEventListener(name, callback, options) {
|
||||||
|
const callbacks = listeners.get(name) || [];
|
||||||
|
callbacks.push({ callback, options });
|
||||||
|
listeners.set(name, callbacks);
|
||||||
|
},
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
timers.push({ callback, delay });
|
||||||
|
return timers.length;
|
||||||
|
},
|
||||||
|
clearTimeout() {},
|
||||||
|
};
|
||||||
|
if (idle) window.requestIdleCallback = callback => idleCallbacks.push(callback);
|
||||||
|
if (existingTodo) window.OtelTodo = { init: config => initializations.push(config) };
|
||||||
|
vm.runInNewContext(source, { window, document: window.document });
|
||||||
|
return {
|
||||||
|
window, scripts, timers, idleCallbacks, listeners, initializations,
|
||||||
|
init(config = {}) {
|
||||||
|
return window.OtelRUM.init({ captureWebVitals: false, ...config });
|
||||||
|
},
|
||||||
|
fire(name, event) {
|
||||||
|
for (const listener of listeners.get(name) || []) {
|
||||||
|
listener.callback(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Todo is disabled by default, including legacy opt-out false', () => {
|
||||||
|
for (const config of [{}, { excludeTodo: false }, { todo: { hideButton: true } }]) {
|
||||||
|
const rum = setup({ readyState: 'complete' });
|
||||||
|
rum.init(config);
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
assert.equal(rum.idleCallbacks.length, 0);
|
||||||
|
assert.equal(rum.timers.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled Todo waits for window load and then idle; RUM starts immediately', () => {
|
||||||
|
const rum = setup();
|
||||||
|
const instance = rum.init({ enableTodo: true });
|
||||||
|
assert.equal(typeof instance.captureException, 'function');
|
||||||
|
assert.ok(rum.listeners.has('error'));
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
assert.equal(rum.idleCallbacks.length, 0);
|
||||||
|
assert.equal(rum.listeners.get('load')[0].options.once, true);
|
||||||
|
rum.window.document.readyState = 'complete';
|
||||||
|
rum.fire('load');
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
assert.equal(rum.idleCallbacks.length, 1);
|
||||||
|
rum.idleCallbacks[0]();
|
||||||
|
assert.equal(rum.scripts.length, 1);
|
||||||
|
assert.equal(rum.scripts[0].src, 'https://assets.example.com/otel-todo.js');
|
||||||
|
assert.equal(rum.scripts[0].async, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializing after load still waits for idle and forwards Todo options', () => {
|
||||||
|
const rum = setup({ readyState: 'complete' });
|
||||||
|
rum.init({
|
||||||
|
enableTodo: true,
|
||||||
|
serviceName: 'checkout',
|
||||||
|
baseUrl: 'https://collector.example.com',
|
||||||
|
ingestKey: 'test-key',
|
||||||
|
todoLibUrl: '/custom-todo.js',
|
||||||
|
todo: { components: ['Cart'], shortcut: 'Alt+T', hideButton: true },
|
||||||
|
});
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
rum.idleCallbacks[0]();
|
||||||
|
assert.equal(rum.scripts[0].src, '/custom-todo.js');
|
||||||
|
let received;
|
||||||
|
rum.window.OtelTodo = { init: config => { received = config; } };
|
||||||
|
rum.scripts[0].onload();
|
||||||
|
assert.equal(received.baseUrl, 'https://collector.example.com');
|
||||||
|
assert.equal(received.serviceName, 'checkout');
|
||||||
|
assert.equal(received.ingestKey, 'test-key');
|
||||||
|
assert.deepEqual(received.components, ['Cart']);
|
||||||
|
assert.equal(received.shortcut, 'Alt+T');
|
||||||
|
assert.equal(received.hideButton, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without idle callbacks Todo waits three seconds after load', () => {
|
||||||
|
const rum = setup({ idle: false });
|
||||||
|
rum.init({ enableTodo: true });
|
||||||
|
assert.equal(rum.timers.length, 0);
|
||||||
|
rum.fire('load');
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
assert.equal(rum.timers.length, 1);
|
||||||
|
assert.equal(rum.timers[0].delay, 3000);
|
||||||
|
rum.timers[0].callback();
|
||||||
|
assert.equal(rum.scripts.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declarative opt-in works and explicit false overrides it', () => {
|
||||||
|
for (const enabled of [true, false]) {
|
||||||
|
const rum = setup({ readyState: 'complete', attributes: { 'data-enable-todo': 'true' } });
|
||||||
|
rum.init(enabled ? {} : { enableTodo: false });
|
||||||
|
assert.equal(rum.idleCallbacks.length, enabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy exclusion vetoes explicit or declarative opt-in', () => {
|
||||||
|
for (const viaAttribute of [true, false]) {
|
||||||
|
const rum = setup({
|
||||||
|
readyState: 'complete',
|
||||||
|
attributes: viaAttribute ? { 'data-enable-todo': 'true', 'data-exclude-todo': 'true' } : {},
|
||||||
|
});
|
||||||
|
rum.init(viaAttribute ? {} : { enableTodo: true, excludeTodo: true });
|
||||||
|
assert.equal(rum.idleCallbacks.length, 0);
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preloaded Todo also waits until idle without injecting another script', () => {
|
||||||
|
const rum = setup({ readyState: 'complete', existingTodo: true });
|
||||||
|
rum.init({ enableTodo: true });
|
||||||
|
assert.equal(rum.initializations.length, 0);
|
||||||
|
rum.idleCallbacks[0]();
|
||||||
|
assert.equal(rum.initializations.length, 1);
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minified RUM preserves declarative initialization and currentScript configuration', () => {
|
||||||
|
const rum = setup({
|
||||||
|
attributes: {
|
||||||
|
'data-service-name': 'checkout',
|
||||||
|
'data-enable-todo': 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(rum.window.OtelRUM.last, undefined);
|
||||||
|
rum.window.document.readyState = 'interactive';
|
||||||
|
rum.fire('DOMContentLoaded');
|
||||||
|
assert.equal(typeof rum.window.OtelRUM.last.captureException, 'function');
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
rum.window.OtelRUM.last.setConsent('granted');
|
||||||
|
assert.equal(rum.scripts[0].src, 'https://assets.example.com/web-vitals.attribution.iife.js');
|
||||||
|
assert.equal(rum.idleCallbacks.length, 0);
|
||||||
|
rum.fire('load');
|
||||||
|
rum.idleCallbacks[0]();
|
||||||
|
let received;
|
||||||
|
rum.window.OtelTodo = { init: config => { received = config; } };
|
||||||
|
rum.scripts[1].onload();
|
||||||
|
assert.equal(received.serviceName, 'checkout');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minified RUM sends OTLP records with configured identity and trace context', () => {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push({ url, options });
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
const instance = rum.init({ consent: 'granted', serviceName: 'checkout', release: 'test-release', ingestKey: 'test-key' });
|
||||||
|
instance.setContext({ plan: 'test' });
|
||||||
|
const traceparent = instance.traceHeaders().traceparent;
|
||||||
|
assert.match(traceparent, /^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/);
|
||||||
|
instance.captureException(new Error('bundle-test'));
|
||||||
|
instance.flush();
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
assert.equal(requests[0].url, 'https://otel.k6n.net/v1/logs?key=test-key');
|
||||||
|
const payload = JSON.parse(requests[0].options.body);
|
||||||
|
assert.equal(payload.resourceLogs[0].resource.attributes[0].value.stringValue, 'checkout');
|
||||||
|
const record = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
|
||||||
|
assert.equal(record.body.stringValue, 'bundle-test');
|
||||||
|
assert.equal(record.traceId, traceparent.slice(3, 35));
|
||||||
|
assert.ok(record.attributes.some(attribute => attribute.key === 'service.release' && attribute.value.stringValue === 'test-release'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minified Todo exposes the standalone API without auto-initializing', () => {
|
||||||
|
const todoSource = readFileSync(new URL('../static/otel-todo.js', import.meta.url), 'utf8');
|
||||||
|
const window = {};
|
||||||
|
vm.runInNewContext(todoSource, { window, document: { currentScript: null } });
|
||||||
|
assert.equal(typeof window.OtelTodo.init, 'function');
|
||||||
|
assert.equal(window.OtelTodo.instance, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anonymous RUM stays active without storage, identifying context, or credentials', () => {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push({ url, options });
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
for (const storage of ['sessionStorage', 'localStorage']) {
|
||||||
|
Object.defineProperty(rum.window, storage, { get() { throw new Error('Storage must not be accessed'); } });
|
||||||
|
}
|
||||||
|
rum.window.location = { href: 'https://example.com/users/private?token=secret#email' };
|
||||||
|
rum.window.navigator = { userAgent: 'identifying-agent' };
|
||||||
|
const instance = rum.init();
|
||||||
|
assert.equal(instance.getConsent(), 'denied');
|
||||||
|
instance.setContext({ user: 'private-user' });
|
||||||
|
instance.addBreadcrumb('private-category', 'private-breadcrumb');
|
||||||
|
instance.captureException(new TypeError('private-error'), { email: 'private-email' });
|
||||||
|
instance.flush();
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
assert.equal(requests[0].options.credentials, 'omit');
|
||||||
|
assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
|
||||||
|
assert.doesNotMatch(requests[0].options.body, /private|secret|identifying-agent|session.id|breadcrumbs|stacktrace/);
|
||||||
|
const record = JSON.parse(requests[0].options.body).resourceLogs[0].scopeLogs[0].logRecords[0];
|
||||||
|
assert.equal(record.body.stringValue, 'Frontend event');
|
||||||
|
assert.ok(record.attributes.some(attribute => attribute.key === 'exception.type' && attribute.value.stringValue === 'TypeError'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grant and withdrawal discard queued data and require fresh identifying context', () => {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push({ url, options });
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
const instance = rum.init();
|
||||||
|
instance.captureMessage('before-grant');
|
||||||
|
instance.setConsent('granted');
|
||||||
|
instance.setContext({ user: 'private-user' });
|
||||||
|
instance.captureMessage('accepted');
|
||||||
|
instance.flush();
|
||||||
|
assert.match(requests[0].options.body, /accepted|private-user/);
|
||||||
|
assert.doesNotMatch(requests[0].options.body, /before-grant/);
|
||||||
|
instance.captureMessage('queued-private-data');
|
||||||
|
instance.setConsent('denied');
|
||||||
|
instance.captureMessage('after-withdrawal');
|
||||||
|
instance.flush();
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
assert.doesNotMatch(requests[1].options.body, /queued-private-data|private-user|session.id|after-withdrawal/);
|
||||||
|
assert.equal(instance.getConsent(), 'denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minimal policy sends only explicitly sanitized diagnostics without visitor context', () => {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push({ url, options });
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
rum.window.location = { href: 'https://example.com/users/private?token=secret' };
|
||||||
|
const instance = rum.init({
|
||||||
|
minimalDiagnostics: {
|
||||||
|
route: '/users/:id',
|
||||||
|
sanitize(event) {
|
||||||
|
assert.equal(event.message, 'Cannot render private-user');
|
||||||
|
return { message: 'Cannot render profile', code: 'PROFILE_RENDER', stacktrace: 'at renderProfile (app.js:10:2)' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
instance.setContext({ user: 'private-user' });
|
||||||
|
instance.captureException(new TypeError('Cannot render private-user'), { token: 'secret' });
|
||||||
|
instance.flush();
|
||||||
|
assert.equal(instance.getPrivacyMode(), 'minimal');
|
||||||
|
assert.match(requests[0].options.body, /Cannot render profile|PROFILE_RENDER|renderProfile/);
|
||||||
|
assert.doesNotMatch(requests[0].options.body, /private-user|secret|session.id|browser.url|breadcrumbs/);
|
||||||
|
const record = JSON.parse(requests[0].options.body).resourceLogs[0].scopeLogs[0].logRecords[0];
|
||||||
|
assert.equal(record.body.stringValue, 'Cannot render profile');
|
||||||
|
assert.ok(record.attributes.some(attribute => attribute.key === 'http.route' && attribute.value.stringValue === '/users/:id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing, rejecting, failing, or malformed sanitizers never expose raw errors', () => {
|
||||||
|
for (const sanitize of [undefined, () => null, () => { throw new Error('private'); }, () => ({ message: 123 }), () => ({ message: '' })]) {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push(options);
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
const instance = rum.init({ minimalDiagnostics: { sanitize } });
|
||||||
|
instance.captureMessage('private-error');
|
||||||
|
instance.flush();
|
||||||
|
assert.doesNotMatch(requests[0].body, /private-error/);
|
||||||
|
assert.equal(JSON.parse(requests[0].body).resourceLogs[0].scopeLogs[0].logRecords[0].body.stringValue, 'Frontend event');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('development mode requires an explicit exact hostname allowlist and is revocable', () => {
|
||||||
|
for (const allowedHosts of [[], ['example.com'], ['dev.example.com.evil'], ['dev.example.com']]) {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push(options);
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
rum.window.location = { href: 'https://dev.example.com/private?token=secret' };
|
||||||
|
const instance = rum.init({ devMode: true, devModeHosts: allowedHosts });
|
||||||
|
const allowed = allowedHosts.includes('dev.example.com');
|
||||||
|
assert.equal(instance.getPrivacyMode(), allowed ? 'development' : 'counts');
|
||||||
|
assert.equal(instance.getConsent(), 'denied');
|
||||||
|
instance.setContext({ user: 'private-user' });
|
||||||
|
instance.captureMessage('actual-dev-error');
|
||||||
|
instance.flush();
|
||||||
|
assert.equal(requests[0].body.includes('actual-dev-error'), allowed);
|
||||||
|
assert.equal(requests[0].body.includes('private-user'), allowed);
|
||||||
|
assert.equal(requests[0].credentials, 'omit');
|
||||||
|
instance.captureMessage('queued-private-error');
|
||||||
|
instance.setConsent('denied');
|
||||||
|
instance.captureMessage('after-revocation');
|
||||||
|
instance.flush();
|
||||||
|
assert.equal(instance.getPrivacyMode(), 'counts');
|
||||||
|
assert.doesNotMatch(requests[1].body, /queued-private-error|after-revocation|private-user|secret/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anonymous vitals retain numeric performance data without identifying metadata', () => {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push({ url, options });
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
rum.window.location = { href: 'https://example.com/private?email=private' };
|
||||||
|
rum.window.navigator = { userAgent: 'private-agent', sendBeacon() { assert.fail('Beacon can send cookies'); } };
|
||||||
|
let onMetric;
|
||||||
|
rum.window.webVitals = { onLCP: callback => { onMetric = callback; } };
|
||||||
|
const instance = rum.init({ captureWebVitals: true, captureWebVitalsWithoutConsent: true });
|
||||||
|
rum.scripts[0].onload();
|
||||||
|
const metric = {
|
||||||
|
name: 'LCP', value: 1234, delta: 1234, id: 'private-metric-id',
|
||||||
|
attribution: { target: '#private-user', url: '/private', timeToFirstByte: 123 },
|
||||||
|
};
|
||||||
|
onMetric(metric);
|
||||||
|
rum.fire('pagehide');
|
||||||
|
const anonymous = JSON.parse(requests[0].options.body);
|
||||||
|
assert.equal(anonymous.metrics.LCP.value, 1234);
|
||||||
|
assert.equal(anonymous.metrics.LCP.attribution.timeToFirstByte, 123);
|
||||||
|
assert.equal(anonymous.session, '');
|
||||||
|
assert.match(anonymous.pageView, /^[a-f0-9]{16}$/);
|
||||||
|
assert.doesNotMatch(requests[0].options.body, /private/);
|
||||||
|
assert.equal(requests[0].options.credentials, 'omit');
|
||||||
|
assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
|
||||||
|
instance.setConsent('granted');
|
||||||
|
rum.fire('pagehide');
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
onMetric(metric);
|
||||||
|
rum.fire('pagehide');
|
||||||
|
assert.match(requests[1].options.body, /private/);
|
||||||
|
assert.notEqual(JSON.parse(requests[1].options.body).pageView, anonymous.pageView);
|
||||||
|
onMetric(metric);
|
||||||
|
instance.setConsent('denied');
|
||||||
|
rum.fire('pagehide');
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
onMetric(metric);
|
||||||
|
rum.fire('pagehide');
|
||||||
|
assert.doesNotMatch(requests[2].options.body, /private/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declarative consent is opt-in and explicit denial takes precedence', () => {
|
||||||
|
for (const granted of [true, false]) {
|
||||||
|
const rum = setup({ attributes: { 'data-consent': 'granted' } });
|
||||||
|
const instance = rum.init(granted ? {} : { consent: 'denied' });
|
||||||
|
assert.equal(instance.getConsent(), granted ? 'granted' : 'denied');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Web Vitals loads only after permission and stops reporting on withdrawal', () => {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push(options);
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
let onMetric;
|
||||||
|
rum.window.webVitals = { onLCP: callback => { onMetric = callback; } };
|
||||||
|
const instance = rum.init({ captureWebVitals: true });
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
instance.setConsent('granted');
|
||||||
|
assert.equal(rum.scripts.length, 1);
|
||||||
|
rum.scripts[0].onload();
|
||||||
|
onMetric({ name: 'LCP', value: 100, delta: 100, id: 'test' });
|
||||||
|
rum.fire('pagehide');
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
instance.setConsent('denied');
|
||||||
|
onMetric({ name: 'LCP', value: 200, delta: 100, id: 'test' });
|
||||||
|
rum.fire('pagehide');
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
instance.setConsent('granted');
|
||||||
|
assert.equal(rum.scripts.length, 1);
|
||||||
|
onMetric({ name: 'LCP', value: 300, delta: 100, id: 'test' });
|
||||||
|
rum.fire('pagehide');
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('permission revoked during the vitals download does not start metric subscriptions', () => {
|
||||||
|
const rum = setup();
|
||||||
|
let subscribed = false;
|
||||||
|
rum.window.webVitals = { onLCP() { subscribed = true; } };
|
||||||
|
const instance = rum.init({ consent: 'granted', captureWebVitals: true });
|
||||||
|
instance.setConsent('denied');
|
||||||
|
rum.scripts[0].onload();
|
||||||
|
assert.equal(subscribed, false);
|
||||||
|
instance.setConsent('granted');
|
||||||
|
assert.equal(rum.scripts.length, 2);
|
||||||
|
rum.scripts[1].onload();
|
||||||
|
assert.equal(subscribed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit false overrides development and independently permitted vitals attributes', () => {
|
||||||
|
const rum = setup({ attributes: {
|
||||||
|
'data-dev-mode': 'true',
|
||||||
|
'data-dev-mode-hosts': 'dev.example.com',
|
||||||
|
'data-vitals-without-consent': 'true',
|
||||||
|
} });
|
||||||
|
rum.window.location = { href: 'https://dev.example.com/' };
|
||||||
|
const instance = rum.init({ devMode: false, captureWebVitals: true, captureWebVitalsWithoutConsent: false });
|
||||||
|
assert.equal(instance.getPrivacyMode(), 'counts');
|
||||||
|
assert.equal(rum.scripts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('development mode works declaratively without fabricating a consent grant', () => {
|
||||||
|
const rum = setup({ attributes: { 'data-dev-mode': 'true', 'data-dev-mode-hosts': 'localhost, dev.example.com' } });
|
||||||
|
rum.window.location = { href: 'https://dev.example.com/' };
|
||||||
|
const instance = rum.init({ captureWebVitals: true });
|
||||||
|
assert.equal(instance.getPrivacyMode(), 'development');
|
||||||
|
assert.equal(instance.getConsent(), 'denied');
|
||||||
|
assert.equal(rum.scripts.length, 1);
|
||||||
|
instance.setConsent('denied');
|
||||||
|
assert.equal(instance.getPrivacyMode(), 'counts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withdrawal restores the independently configured minimal diagnostics policy', () => {
|
||||||
|
const requests = [];
|
||||||
|
const rum = setup({ fetch: (url, options) => {
|
||||||
|
requests.push(options);
|
||||||
|
return Promise.resolve({ status: 200 });
|
||||||
|
} });
|
||||||
|
const instance = rum.init({
|
||||||
|
consent: 'granted',
|
||||||
|
minimalDiagnostics: { sanitize: () => ({ message: 'Safe diagnostic' }) },
|
||||||
|
});
|
||||||
|
instance.captureMessage('queued-private');
|
||||||
|
instance.setConsent('denied');
|
||||||
|
instance.captureMessage('private-content');
|
||||||
|
instance.flush();
|
||||||
|
assert.equal(instance.getPrivacyMode(), 'minimal');
|
||||||
|
assert.match(requests[0].body, /Safe diagnostic/);
|
||||||
|
assert.doesNotMatch(requests[0].body, /private/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential-free telemetry never falls back to a cookie-bearing beacon', async () => {
|
||||||
|
for (const consent of ['denied', 'granted']) {
|
||||||
|
let beacons = 0;
|
||||||
|
const rum = setup({ fetch: () => Promise.reject(new Error('offline')) });
|
||||||
|
rum.window.navigator = { sendBeacon() { beacons++; } };
|
||||||
|
const instance = rum.init({ consent });
|
||||||
|
instance.captureMessage('test');
|
||||||
|
instance.flush();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(beacons, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Todo stays usable without storage and reads current consent for every submission', () => {
|
||||||
|
const requests = [];
|
||||||
|
const handlers = new Map();
|
||||||
|
const element = () => ({
|
||||||
|
addEventListener() {},
|
||||||
|
setAttribute() {},
|
||||||
|
classList: { remove() {} },
|
||||||
|
});
|
||||||
|
const text = { value: 'A deliberately submitted note' };
|
||||||
|
const ref = { ...element(), querySelector: () => element() };
|
||||||
|
const form = {
|
||||||
|
elements: { namedItem: name => name === 'text' ? text : { value: 'Cart' } },
|
||||||
|
querySelector: selector => selector === '.otel-todo-ref' ? ref : element(),
|
||||||
|
addEventListener: (name, handler) => handlers.set(name, handler),
|
||||||
|
};
|
||||||
|
const dialog = { ...element(), firstElementChild: form };
|
||||||
|
const document = {
|
||||||
|
currentScript: null,
|
||||||
|
createElement: tag => tag === 'dialog' ? dialog : element(),
|
||||||
|
head: { appendChild() {} },
|
||||||
|
body: { appendChild() {} },
|
||||||
|
};
|
||||||
|
const window = {
|
||||||
|
addEventListener() {},
|
||||||
|
fetch(url, options) {
|
||||||
|
requests.push({ url, options });
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const storage of ['sessionStorage', 'localStorage']) {
|
||||||
|
Object.defineProperty(window, storage, { get() { assert.fail('Todo must not access storage'); } });
|
||||||
|
}
|
||||||
|
vm.runInNewContext(readFileSync(new URL('../static/otel-todo.js', import.meta.url), 'utf8'), {
|
||||||
|
window, document,
|
||||||
|
location: { pathname: '/private-user', hash: '#private', href: 'https://example.com/private' },
|
||||||
|
navigator: { userAgent: 'private-agent' },
|
||||||
|
});
|
||||||
|
const instance = window.OtelTodo.init({ hideButton: true });
|
||||||
|
const submit = () => handlers.get('submit')({ preventDefault() {} });
|
||||||
|
submit();
|
||||||
|
assert.equal(JSON.parse(requests[0].options.body).text, text.value);
|
||||||
|
assert.doesNotMatch(requests[0].options.body, /private/);
|
||||||
|
assert.equal(requests[0].options.credentials, 'omit');
|
||||||
|
assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
|
||||||
|
instance.setConsent('granted');
|
||||||
|
submit();
|
||||||
|
assert.match(requests[1].options.body, /private/);
|
||||||
|
instance.setConsent('denied');
|
||||||
|
submit();
|
||||||
|
assert.doesNotMatch(requests[2].options.body, /private/);
|
||||||
|
});
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
|
|
||||||
# Use pre-configured virtual environment or fall back to local .venv
|
|
||||||
VENV_DIR="/home/mats/.gemini/antigravity-cli/brain/8e7a2c5c-81aa-487e-81b1-bf0cf790d055/scratch/needle_venv"
|
|
||||||
if [ ! -d "${VENV_DIR}" ]; then
|
|
||||||
VENV_DIR="${ROOT_DIR}/.venv"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "${VENV_DIR}/bin/python" ]; then
|
|
||||||
echo "Setting up virtual environment at ${VENV_DIR}..."
|
|
||||||
python3 -m venv "${VENV_DIR}"
|
|
||||||
"${VENV_DIR}/bin/pip" install --upgrade pip
|
|
||||||
"${VENV_DIR}/bin/pip" install cactus-needle pydantic
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running Needle 3 OTel Verification Suite..."
|
|
||||||
"${VENV_DIR}/bin/python" "${SCRIPT_DIR}/test_needle_classifier.py"
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test and Verification Suite for Cactus-Compute/needle3 with OpenTelemetry data.
|
|
||||||
Tests:
|
|
||||||
1. Action/Tool Dispatch Accuracy (Ground Truth Verification)
|
|
||||||
2. Field Extraction & Grounding
|
|
||||||
3. Confidence Calibrations (Act vs. Escalate)
|
|
||||||
4. Semantic Vector Embeddings (Issue Clustering)
|
|
||||||
5. Execution Performance & Latency (Tokens/sec)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
try:
|
|
||||||
import needle
|
|
||||||
except ImportError:
|
|
||||||
print("Error: cactus-needle not found. Run with the virtualenv python:")
|
|
||||||
print(" /home/mats/.gemini/antigravity-cli/brain/8e7a2c5c-81aa-487e-81b1-bf0cf790d055/scratch/needle_venv/bin/python " + __file__)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Define Triage Tools ---
|
|
||||||
@needle.tool
|
|
||||||
def report_client_javascript_crash(service: str, message: str, url: str):
|
|
||||||
"""Report client-side JavaScript crash or error logged by browser RUM."""
|
|
||||||
return {"action": "client_js_crash", "service": service, "message": message, "url": url}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def alert_vulnerability_probe(scanner: str, target: str, status_code: int):
|
|
||||||
"""Alert on malicious vulnerability scans or exploit crawlers."""
|
|
||||||
return {"action": "security_alert", "scanner": scanner, "target": target, "status_code": status_code}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def record_frontend_web_vital(service: str, metric_name: str, duration_ms: int):
|
|
||||||
"""Record RUM performance metrics like TTFB or FCP."""
|
|
||||||
return {"action": "record_vital", "service": service, "metric": metric_name, "ms": duration_ms}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def log_missing_resource_404(path: str, host: str):
|
|
||||||
"""Log benign 404 Not Found requests for missing pages or git commits."""
|
|
||||||
return {"action": "log_404", "path": path, "host": host}
|
|
||||||
|
|
||||||
@needle.tool
|
|
||||||
def escalate_backend_outage(service: str, error_message: str):
|
|
||||||
"""Escalate severe backend database timeout or service crash to on-call."""
|
|
||||||
return {"action": "escalate_outage", "service": service, "error": error_message}
|
|
||||||
|
|
||||||
|
|
||||||
def run_verification():
|
|
||||||
print("=" * 70)
|
|
||||||
print(" NEEDLE 3 OPENTELEMETRY CLASSIFIER - VERIFICATION SUITE")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# 1. Initialize Agent
|
|
||||||
print("\n[Step 1] Initializing Needle 3 Engine...")
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
agent = needle.Needle(tools=[
|
|
||||||
report_client_javascript_crash,
|
|
||||||
alert_vulnerability_probe,
|
|
||||||
record_frontend_web_vital,
|
|
||||||
log_missing_resource_404,
|
|
||||||
escalate_backend_outage
|
|
||||||
])
|
|
||||||
init_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
print(f"✓ Needle 3 Engine initialized in {init_ms:.1f}ms")
|
|
||||||
|
|
||||||
# 2. Ground-Truth Test Cases (from actual ClickHouse telemetry)
|
|
||||||
test_cases = [
|
|
||||||
{
|
|
||||||
"name": "Frontend Browser RUM Crash",
|
|
||||||
"input": "User reported client browser crash on service eventsourced-ai-web: Script error on url https://event-agent.k6n.net/#session=angry-koala-1",
|
|
||||||
"expected_tool": "report_client_javascript_crash",
|
|
||||||
"expected_fields": ["service", "url"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Malicious CVE Exploit Probe",
|
|
||||||
"input": "Alert security team: scanner metabase-cve-2026-72898-detect/1.0 probed target /api/session/properties with status code 503",
|
|
||||||
"expected_tool": "alert_vulnerability_probe",
|
|
||||||
"expected_fields": ["target", "status_code"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Web Vital RUM Metric",
|
|
||||||
"input": "Store RUM metric on service storefront-web: record TTFB of 84 ms",
|
|
||||||
"expected_tool": "record_frontend_web_vital",
|
|
||||||
"expected_fields": ["service", "metric", "ms"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Nginx Benign 404 Route",
|
|
||||||
"input": "Client requested missing git resource: 404 on path /mats/go-cart-actor/blame/commit/81569b on host git.k6n.net",
|
|
||||||
"expected_tool": "log_missing_resource_404",
|
|
||||||
"expected_fields": ["path", "host"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Critical Database Outage",
|
|
||||||
"input": "Critical incident: Escalate severe backend database timeout on service checkout-service: deadlocked while acquiring connection pool",
|
|
||||||
"expected_tool": "escalate_backend_outage",
|
|
||||||
"expected_fields": ["service"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
print("\n[Step 2] Testing Classification & Tool Dispatch Accuracy...")
|
|
||||||
passed = 0
|
|
||||||
total = len(test_cases)
|
|
||||||
latencies = []
|
|
||||||
|
|
||||||
for i, tc in enumerate(test_cases, 1):
|
|
||||||
agent.reset()
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
res = agent.run(tc["input"], strict=False)
|
|
||||||
dur_ms = (time.perf_counter() - t_start) * 1000
|
|
||||||
latencies.append(dur_ms)
|
|
||||||
|
|
||||||
results = res.get("results", [])
|
|
||||||
matched_action = results[0].get("action") if results and isinstance(results[0], dict) else None
|
|
||||||
|
|
||||||
# Tool name to action map
|
|
||||||
expected_action = tc["expected_tool"].replace("report_", "").replace("alert_", "").replace("record_", "").replace("log_", "").replace("escalate_", "")
|
|
||||||
# Check match
|
|
||||||
is_match = False
|
|
||||||
if results and any(tc["expected_tool"] in str(c) or matched_action for c in results):
|
|
||||||
is_match = True
|
|
||||||
|
|
||||||
status_str = "PASS" if is_match else "FAIL"
|
|
||||||
if is_match:
|
|
||||||
passed += 1
|
|
||||||
|
|
||||||
print(f"\n Test {i}/{total}: {tc['name']} -> [{status_str}] ({dur_ms:.1f}ms)")
|
|
||||||
print(f" Confidence: {res.get('confidence', 0.0):.3f}")
|
|
||||||
print(f" Reasoning : {res.get('reasoning')}")
|
|
||||||
print(f" Dispatched: {results}")
|
|
||||||
|
|
||||||
print("\n" + "-" * 70)
|
|
||||||
print(f"Accuracy Score: {passed}/{total} ({passed/total*100:.0f}%) | Avg Latency: {sum(latencies)/len(latencies):.1f}ms")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
# 3. Embedding & Semantic Search Verification
|
|
||||||
print("\n[Step 3] Testing Semantic Vector Embeddings for Near-Match Clustering...")
|
|
||||||
emb_e1 = agent.embed("database connection timeout after 3000ms")
|
|
||||||
emb_e2 = agent.embed("postgres pool exhausted: connection timed out")
|
|
||||||
emb_e3 = agent.embed("invalid JWT signature token expired")
|
|
||||||
|
|
||||||
def cosine_similarity(v1, v2):
|
|
||||||
dot = sum(a * b for a, b in zip(v1, v2))
|
|
||||||
m1 = math.sqrt(sum(a * a for a in v1))
|
|
||||||
m2 = math.sqrt(sum(b * b for b in v2))
|
|
||||||
return dot / (m1 * m2)
|
|
||||||
|
|
||||||
sim_similar = cosine_similarity(emb_e1, emb_e2)
|
|
||||||
sim_different = cosine_similarity(emb_e1, emb_e3)
|
|
||||||
|
|
||||||
print(f" Vector Dimensions: {len(emb_e1)}")
|
|
||||||
print(f" Similarity between 2 DB timeouts : {sim_similar:.4f}")
|
|
||||||
print(f" Similarity between DB and Auth : {sim_different:.4f}")
|
|
||||||
|
|
||||||
if sim_similar > sim_different:
|
|
||||||
print("✓ Semantic clustering validated: related issues score higher similarity.")
|
|
||||||
else:
|
|
||||||
print("✗ Warning: semantic separation failed.")
|
|
||||||
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print(" VERIFICATION COMPLETE")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run_verification()
|
|
||||||
+1
-745
@@ -1,745 +1 @@
|
|||||||
/*!
|
"use strict";(()=>{(function(e){"use strict";var ge="https://otel.k6n.net",a=null;try{a=typeof document<"u"?document.currentScript||(function(){for(var i=document.getElementsByTagName("script"),d=i.length-1;d>=0;d--)if(i[d].src&&i[d].src.indexOf("otel-rum")!==-1)return i[d];return null})():null}catch{}var V=typeof e.fetch=="function"?e.fetch:null,Ze=typeof e.XMLHttpRequest=="function"?e.XMLHttpRequest:null;function w(i){for(var d="0123456789abcdef",l="",y=0;y<i;y++)l+=d[Math.random()*16|0];return l}function W(){return w(32)}function X(){return w(16)}function Se(i){try{var d=new e.URL(i,e.location.href);return d.origin===e.location.origin}catch{return!1}}function q(i){var d=[];for(var l in i)if(Object.prototype.hasOwnProperty.call(i,l)){var y=i[l];y==null||y===""||d.push({key:l,value:{stringValue:String(y)}})}return d}function Ee(i={}){i=i||{};var d=a&&(a.getAttribute("data-service-name")||a.getAttribute("data-service"))||"",l=a&&a.getAttribute("data-base-url")||"",y=a&&a.getAttribute("data-endpoint")||"",we=a&&a.getAttribute("data-ingest-key")||"",x=(i.baseUrl||l||ge).replace(/\/+$/,""),R=i.endpoint||y;R?!/^https?:\/\//i.test(R)&&!R.startsWith("//")&&(R=x+"/"+R.replace(/^\/+/,"")):R=x+"/v1/logs";var Le=i.vitalsEndpoint||a&&a.getAttribute("data-vitals-endpoint")||x+"/v1/vitals",Te=i.vitalsLibUrl||a&&a.getAttribute("data-vitals-lib")||(a&&a.src?a.src.replace(/[^\/]*$/,"")+"web-vitals.attribution.iife.js":x+"/static/web-vitals.attribution.iife.js");function re(r){if(!r)return!1;var t=String(r);return!!(R&&t.indexOf(R)===0||t.indexOf("/v1/logs")!==-1||t.indexOf("/v1/traces")!==-1||t.indexOf("/v1/metrics")!==-1||t.indexOf("/v1/vitals")!==-1)}var _=i.serviceName||d||"frontend",G=i.environment||a&&a.getAttribute("data-environment")||"",K=i.release||a&&a.getAttribute("data-release")||"",L=i.ingestKey||we||"",H=(i.consent||a&&a.getAttribute("data-consent"))==="granted"?"granted":"denied",be=typeof i.devMode=="boolean"?i.devMode:!!(a&&a.getAttribute("data-dev-mode")==="true"),xe=i.devModeHosts||(a&&a.getAttribute("data-dev-mode-hosts")||"").split(",").map(function(r){return r.trim()}).filter(Boolean),U=!1;try{U=be&&xe.indexOf(new e.URL(e.location.href).hostname)!==-1}catch{}var D=i.minimalDiagnostics,ne=[],P=typeof i.sampleRate=="number"?i.sampleRate:1,Oe=i.maxBreadcrumbs||20,Ae=i.captureConsoleErrors!==!1,Me=i.autoInstrumentFetch!==!1,Ce=i.autoInstrumentXhr!==!1,ke=i.trackSpaRoutes!==!1,z=i.reportHttpFailures!==!1,He=i.captureWebVitals!==!1,Ue=typeof i.captureWebVitalsWithoutConsent=="boolean"?i.captureWebVitalsWithoutConsent:!!(a&&a.getAttribute("data-vitals-without-consent")==="true"),$=!1,De=typeof i.enableTodo=="boolean"?i.enableTodo:!!(a&&a.getAttribute("data-enable-todo")==="true"),Ie=i.excludeTodo===!0||a&&a.getAttribute("data-exclude-todo")==="true",Pe=i.todoLibUrl||a&&a.getAttribute("data-todo-lib")||(a&&a.src?a.src.replace(/[^\/]*$/,"")+"otel-todo.js":x+"/static/otel-todo.js"),Fe=i.injectTraceHeaders!==!1,ie=i.traceHeaderOrigins,Ne=i.maxEventsPerMinute||60,je=i.batchSize||10,Be=i.flushIntervalMs||5e3,O=[],F="",N=T()?w(16):"",A=[],E=null,I=[],Ve=0;function We(r){var t=r==="granted"?"granted":"denied",n=t==="denied"&&U;H===t&&!n||(n&&(U=!1),H=t,A=[],E&&e.clearTimeout(E),E=null,O=[],B={},F="",N=T()?w(16):"",ne.forEach(function(s){s()}),oe())}function ae(){return H}function T(){return H==="granted"||U}function se(){return U?"development":H==="granted"?"consented":D?"minimal":"counts"}function j(){return He&&(T()||Ue)}function oe(){$||!j()||($=!0,Ke())}function Xe(){for(var r=Date.now();I.length&&r-I[0]>6e4;)I.shift();return I.length>=Ne?!1:(I.push(r),!0)}function qe(r){if(A.push(r),A.length>=je){M();return}E||(E=e.setTimeout(M,Be))}function M(){if(E&&(e.clearTimeout(E),E=null),!!A.length){var r=A.splice(0,A.length);_e(r)}}function _e(r){var t={resourceLogs:[{resource:{attributes:q({"service.name":_})},scopeLogs:[{scope:{name:"otel-rum"},logRecords:r}]}]},n=JSON.stringify(t),s=R;L&&(s+=(s.indexOf("?")===-1?"?":"&")+"key="+encodeURIComponent(L));var o=V||(typeof e.fetch=="function"?e.fetch:null);if(o){var c={"Content-Type":"application/json"};L&&(c["X-Otel-Ingest-Key"]=L),o.call(e,s,{method:"POST",mode:"cors",credentials:"omit",referrerPolicy:"no-referrer",headers:c,body:n,keepalive:!0}).catch(function(){});return}}var J={DEBUG:5,INFO:9,WARN:13,ERROR:17,FATAL:21};function Ge(r,t,n,s){if(!T()){var o={source:"frontend","service.release":K,"service.environment":G,"privacy.mode":"counts"},c=n&&n["exception.type"];typeof c=="string"&&["Error","TypeError","ReferenceError","SyntaxError","RangeError","URIError","EvalError","UnhandledRejection","HttpFailure","ResourceLoadError"].indexOf(c)!==-1&&(o["exception.type"]=c);var p=n&&String(n["http.response.status_code"]||"");p&&/^[1-5][0-9]{2}$/.test(p)&&(o["http.response.status_code"]=p);var g="Frontend event";if(D)try{var f=D.sanitize({message:String(t).slice(0,2e3),type:typeof c=="string"?c.slice(0,100):void 0,stacktrace:n&&typeof n["exception.stacktrace"]=="string"?n["exLine truncated
|
||||||
* otel-rum.js - browser error/RUM collector for go-otel.
|
|
||||||
*
|
|
||||||
* Captures uncaught errors, unhandled promise rejections, failed resource
|
|
||||||
* loads, failing HTTP requests and Web Vitals (LCP/INP/CLS/TTFB/FCP), and
|
|
||||||
* ships errors as OTLP log records to this server's existing /v1/logs endpoint
|
|
||||||
* (no separate ingestion path needed - the backend's issue view picks these
|
|
||||||
* up like any other error log). Web Vitals go out separately: one beacon per
|
|
||||||
* page view to /v1/vitals, measured with the vendored web-vitals library.
|
|
||||||
*
|
|
||||||
* Also exposes a `traceHeaders()` helper so outgoing `fetch`/XHR calls can
|
|
||||||
* carry a W3C `traceparent` header. If the backend service is itself
|
|
||||||
* OTel-instrumented and honors incoming trace context (most server
|
|
||||||
* instrumentation does by default), spans it creates for that request will
|
|
||||||
* share the same trace id - so a frontend error and the backend spans it
|
|
||||||
* triggered can be found together via GET /api/traces/{traceId}.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* <script src="https://otel.k6n.net/static/otel-rum.js"></script>
|
|
||||||
* <script>
|
|
||||||
* var rum = OtelRUM.init({
|
|
||||||
* serviceName: 'my-web-app',
|
|
||||||
* baseUrl: 'https://otel.example.com', // optional collector override
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Manual correlation-id injection into any HTTP client:
|
|
||||||
* fetch('/api/orders', { headers: rum.traceHeaders() });
|
|
||||||
*
|
|
||||||
* // Manual error reporting:
|
|
||||||
* try { risky(); } catch (err) { rum.captureException(err); }
|
|
||||||
* </script>
|
|
||||||
*
|
|
||||||
* Events are batched client-side (flushed on pagehide via keepalive fetch/sendBeacon) and
|
|
||||||
* rate-limited, so a runaway error loop cannot hammer the collector.
|
|
||||||
*
|
|
||||||
* Source-map resolution is performed server-side by go-otel when a matching
|
|
||||||
* release map has been uploaded. Session replay and PII scrubbing remain out
|
|
||||||
* of scope; don't route sensitive data through breadcrumbs/attributes.
|
|
||||||
*/
|
|
||||||
(function (global) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var DEFAULT_BASE_URL = 'https://otel.k6n.net';
|
|
||||||
|
|
||||||
// Find the loading script so declarative data-* configuration keeps working.
|
|
||||||
try {
|
|
||||||
var scriptEl = (typeof document !== 'undefined') ? (document.currentScript || (function() {
|
|
||||||
var scripts = document.getElementsByTagName('script');
|
|
||||||
for (var i = scripts.length - 1; i >= 0; i--) {
|
|
||||||
if (scripts[i].src && scripts[i].src.indexOf('otel-rum') !== -1) return scripts[i];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()) : null;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
var rawFetch = typeof global.fetch === 'function' ? global.fetch : null;
|
|
||||||
var rawXHR = typeof global.XMLHttpRequest === 'function' ? global.XMLHttpRequest : null;
|
|
||||||
|
|
||||||
function randomHex(len) {
|
|
||||||
var chars = '0123456789abcdef';
|
|
||||||
var out = '';
|
|
||||||
for (var i = 0; i < len; i++) {
|
|
||||||
out += chars[(Math.random() * 16) | 0];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// W3C Trace Context ids: 16-byte trace id, 8-byte span id, both hex.
|
|
||||||
function newTraceId() {
|
|
||||||
return randomHex(32);
|
|
||||||
}
|
|
||||||
|
|
||||||
function newSpanId() {
|
|
||||||
return randomHex(16);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameOrigin(url) {
|
|
||||||
try {
|
|
||||||
var resolved = new global.URL(url, global.location.href);
|
|
||||||
return resolved.origin === global.location.origin;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOtlpAttributes(obj) {
|
|
||||||
var out = [];
|
|
||||||
for (var key in obj) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
||||||
var value = obj[key];
|
|
||||||
if (value === undefined || value === null || value === '') continue;
|
|
||||||
out.push({ key: key, value: { stringValue: String(value) } });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(config) {
|
|
||||||
config = config || {};
|
|
||||||
|
|
||||||
var scriptServiceName = (scriptEl && (scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) || '';
|
|
||||||
var scriptBaseUrl = (scriptEl && scriptEl.getAttribute('data-base-url')) || '';
|
|
||||||
var scriptEndpoint = (scriptEl && scriptEl.getAttribute('data-endpoint')) || '';
|
|
||||||
var scriptIngestKey = (scriptEl && scriptEl.getAttribute('data-ingest-key')) || '';
|
|
||||||
|
|
||||||
// Collector base URL resolution:
|
|
||||||
// 1. Explicit config.baseUrl or scriptEl data-base-url
|
|
||||||
// 2. Production collector: https://otel.k6n.net
|
|
||||||
// Do not derive this from the page or script origin: the script may be
|
|
||||||
// bundled or self-hosted while logs still belong at the collector.
|
|
||||||
var baseUrl = (config.baseUrl || scriptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
||||||
var endpoint = config.endpoint || scriptEndpoint;
|
|
||||||
if (!endpoint) {
|
|
||||||
endpoint = baseUrl + '/v1/logs';
|
|
||||||
} else if (!/^https?:\/\//i.test(endpoint) && !endpoint.startsWith('//')) {
|
|
||||||
endpoint = baseUrl + '/' + endpoint.replace(/^\/+/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
var vitalsEndpoint = config.vitalsEndpoint || (scriptEl && scriptEl.getAttribute('data-vitals-endpoint')) || baseUrl + '/v1/vitals';
|
|
||||||
// The web-vitals build lives next to this script; fall back to the collector's /static.
|
|
||||||
var vitalsLibUrl =
|
|
||||||
config.vitalsLibUrl ||
|
|
||||||
(scriptEl && scriptEl.getAttribute('data-vitals-lib')) ||
|
|
||||||
(scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'web-vitals.attribution.iife.js' : baseUrl + '/static/web-vitals.attribution.iife.js');
|
|
||||||
|
|
||||||
function isCollectorUrl(u) {
|
|
||||||
if (!u) return false;
|
|
||||||
var s = String(u);
|
|
||||||
if (endpoint && s.indexOf(endpoint) === 0) return true;
|
|
||||||
if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1 || s.indexOf('/v1/vitals') !== -1) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var serviceName = config.serviceName || scriptServiceName || 'frontend';
|
|
||||||
var environment = config.environment || (scriptEl && scriptEl.getAttribute('data-environment')) || '';
|
|
||||||
var release = config.release || (scriptEl && scriptEl.getAttribute('data-release')) || '';
|
|
||||||
var ingestKey = config.ingestKey || scriptIngestKey || '';
|
|
||||||
var sampleRate = typeof config.sampleRate === 'number' ? config.sampleRate : 1;
|
|
||||||
var maxBreadcrumbs = config.maxBreadcrumbs || 20;
|
|
||||||
var captureConsoleErrors = config.captureConsoleErrors !== false;
|
|
||||||
var autoInstrumentFetch = config.autoInstrumentFetch !== false;
|
|
||||||
var autoInstrumentXhr = config.autoInstrumentXhr !== false;
|
|
||||||
var trackSpaRoutes = config.trackSpaRoutes !== false;
|
|
||||||
var reportHttpFailures = config.reportHttpFailures !== false;
|
|
||||||
var captureWebVitals = config.captureWebVitals !== false;
|
|
||||||
// The todo widget (otel-todo.js) is loaded automatically unless excluded:
|
|
||||||
// init({ excludeTodo: true }) or data-exclude-todo="true".
|
|
||||||
var excludeTodo = config.excludeTodo === true || (scriptEl && scriptEl.getAttribute('data-exclude-todo') === 'true');
|
|
||||||
var todoLibUrl =
|
|
||||||
config.todoLibUrl ||
|
|
||||||
(scriptEl && scriptEl.getAttribute('data-todo-lib')) ||
|
|
||||||
(scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'otel-todo.js' : baseUrl + '/static/otel-todo.js');
|
|
||||||
var injectTraceHeaders = config.injectTraceHeaders !== false;
|
|
||||||
var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only
|
|
||||||
// Client-side rate limiting: max events per rolling minute.
|
|
||||||
var maxEventsPerMinute = config.maxEventsPerMinute || 60;
|
|
||||||
// Batch size / flush interval for queued records.
|
|
||||||
var batchSize = config.batchSize || 10;
|
|
||||||
var flushIntervalMs = config.flushIntervalMs || 5000;
|
|
||||||
|
|
||||||
var breadcrumbs = [];
|
|
||||||
// Best-effort correlation: the most recently generated trace id is
|
|
||||||
// attached to the next error report, so an error surfacing shortly
|
|
||||||
// after a failed request can usually be found alongside it. This is
|
|
||||||
// not a real per-async-context propagation mechanism.
|
|
||||||
var lastTraceId = '';
|
|
||||||
|
|
||||||
// Session id: survives reloads within the tab's lifetime, so server
|
|
||||||
//-side issue views can group frontend errors by browsing session.
|
|
||||||
var sessionId = '';
|
|
||||||
try {
|
|
||||||
sessionId = global.sessionStorage.getItem('otel-rum-session');
|
|
||||||
if (!sessionId) {
|
|
||||||
sessionId = randomHex(16);
|
|
||||||
global.sessionStorage.setItem('otel-rum-session', sessionId);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
sessionId = randomHex(16); // storage blocked (privacy mode etc.)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Client-side queue, batching and rate limiting -----------------
|
|
||||||
|
|
||||||
var queue = [];
|
|
||||||
var flushTimer = null;
|
|
||||||
var sentTimestamps = [];
|
|
||||||
var eventsDropped = 0;
|
|
||||||
|
|
||||||
function underRateLimit() {
|
|
||||||
var now = Date.now();
|
|
||||||
// Drop timestamps older than the rolling 60s window.
|
|
||||||
while (sentTimestamps.length && now - sentTimestamps[0] > 60000) {
|
|
||||||
sentTimestamps.shift();
|
|
||||||
}
|
|
||||||
if (sentTimestamps.length >= maxEventsPerMinute) return false;
|
|
||||||
sentTimestamps.push(now);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueue(record) {
|
|
||||||
queue.push(record);
|
|
||||||
if (queue.length >= batchSize) {
|
|
||||||
flush();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!flushTimer) {
|
|
||||||
flushTimer = global.setTimeout(flush, flushIntervalMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flush() {
|
|
||||||
if (flushTimer) {
|
|
||||||
global.clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
if (!queue.length) return;
|
|
||||||
var records = queue.splice(0, queue.length);
|
|
||||||
ship(records);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ship sends already-built log records in one OTLP payload. Uses
|
|
||||||
// sendBeacon when available (survives page unload), else keepalive
|
|
||||||
// fetch.
|
|
||||||
function ship(records) {
|
|
||||||
var payload = {
|
|
||||||
resourceLogs: [
|
|
||||||
{
|
|
||||||
resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) },
|
|
||||||
scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: records }]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
var body = JSON.stringify(payload);
|
|
||||||
var url = endpoint;
|
|
||||||
if (ingestKey) {
|
|
||||||
url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use uninstrumented rawFetch with keepalive: true and mode: 'cors'
|
|
||||||
// to avoid intercepting and looping on our own telemetry requests.
|
|
||||||
var fetchFn = rawFetch || (typeof global.fetch === 'function' ? global.fetch : null);
|
|
||||||
if (fetchFn) {
|
|
||||||
var fetchHeaders = { 'Content-Type': 'application/json' };
|
|
||||||
if (ingestKey) {
|
|
||||||
fetchHeaders['X-Otel-Ingest-Key'] = ingestKey;
|
|
||||||
}
|
|
||||||
fetchFn.call(global, url, {
|
|
||||||
method: 'POST',
|
|
||||||
mode: 'cors',
|
|
||||||
headers: fetchHeaders,
|
|
||||||
body: body,
|
|
||||||
keepalive: true
|
|
||||||
}).catch(function () {
|
|
||||||
if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
|
|
||||||
try {
|
|
||||||
var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' });
|
|
||||||
global.navigator.sendBeacon(url, blob);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
|
|
||||||
try {
|
|
||||||
var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' });
|
|
||||||
global.navigator.sendBeacon(url, blob);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var severityNumbers = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17, FATAL: 21 };
|
|
||||||
|
|
||||||
function buildRecord(severity, message, attrs, traceId) {
|
|
||||||
var record = {
|
|
||||||
timeUnixNano: String(Date.now() * 1e6),
|
|
||||||
severityText: severity,
|
|
||||||
severityNumber: severityNumbers[severity] || 17,
|
|
||||||
body: { stringValue: String(message).slice(0, 2000) },
|
|
||||||
attributes: toOtlpAttributes(
|
|
||||||
Object.assign(
|
|
||||||
{},
|
|
||||||
contextAttrs,
|
|
||||||
{
|
|
||||||
source: 'frontend',
|
|
||||||
'browser.url': global.location ? global.location.href : '',
|
|
||||||
'browser.userAgent': global.navigator ? global.navigator.userAgent : '',
|
|
||||||
'browser.viewport': global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
|
|
||||||
'session.id': sessionId,
|
|
||||||
'service.release': release,
|
|
||||||
'service.environment': environment,
|
|
||||||
breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined
|
|
||||||
},
|
|
||||||
attrs || {}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if (traceId) {
|
|
||||||
record.traceId = traceId;
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(severity, message, attrs, traceId) {
|
|
||||||
if (sampleRate < 1 && Math.random() > sampleRate) return;
|
|
||||||
if (!underRateLimit()) {
|
|
||||||
eventsDropped++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
enqueue(buildRecord(severity, message, attrs, traceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web Vitals: measured by the vendored `web-vitals` library (attribution
|
|
||||||
// build, loaded lazily so it never blocks the page) and reported as ONE
|
|
||||||
// beacon per page view to /v1/vitals when the page is hidden. The
|
|
||||||
// beacon carries every metric with its attribution (LCP element, INP
|
|
||||||
// target/breakdown, CLS shift source...). If the page is hidden again
|
|
||||||
// later (tab switch) and a metric changed - typically INP - a fuller
|
|
||||||
// snapshot with the same pageView id is sent; the newest one wins.
|
|
||||||
function observeVitals() {
|
|
||||||
var pageViewId = randomHex(16);
|
|
||||||
var vitals = {};
|
|
||||||
var navType = '';
|
|
||||||
var dirty = false;
|
|
||||||
var sampled = sampleRate >= 1 || Math.random() <= sampleRate;
|
|
||||||
|
|
||||||
var picks = {
|
|
||||||
LCP: ['target', 'url', 'timeToFirstByte', 'resourceLoadDelay', 'resourceLoadDuration', 'elementRenderDelay'],
|
|
||||||
INP: ['interactionTarget', 'interactionType', 'inputDelay', 'processingDuration', 'presentationDelay', 'loadState', 'totalScriptDuration', 'totalStyleAndLayoutDuration', 'totalPaintDuration'],
|
|
||||||
CLS: ['largestShiftTarget', 'largestShiftValue', 'largestShiftTime', 'loadState'],
|
|
||||||
FCP: ['timeToFirstByte', 'firstByteToFCP', 'loadState'],
|
|
||||||
TTFB: ['waitingDuration', 'cacheDuration', 'dnsDuration', 'connectionDuration', 'requestDuration']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Values keep 3 decimals: CLS is a tiny fraction and must not be rounded to an integer.
|
|
||||||
function round(n) {
|
|
||||||
return typeof n === 'number' ? Math.round(n * 1000) / 1000 : n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slim(metric) {
|
|
||||||
var out = {};
|
|
||||||
var src = metric.attribution || {};
|
|
||||||
(picks[metric.name] || []).forEach(function (k) {
|
|
||||||
if (src[k] !== undefined && src[k] !== null) out[k] = round(src[k]);
|
|
||||||
});
|
|
||||||
var script = src.longestScript && src.longestScript.entry;
|
|
||||||
if (script && script.sourceURL) {
|
|
||||||
out.longestScriptUrl = script.sourceURL;
|
|
||||||
if (script.sourceFunctionName) out.longestScriptFunction = script.sourceFunctionName;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMetric(metric) {
|
|
||||||
vitals[metric.name] = { value: round(metric.value), delta: round(metric.delta), id: metric.id, attribution: slim(metric) };
|
|
||||||
navType = metric.navigationType || navType;
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendVitals() {
|
|
||||||
if (!dirty || !sampled) return;
|
|
||||||
dirty = false;
|
|
||||||
var body = JSON.stringify({
|
|
||||||
service: serviceName,
|
|
||||||
environment: environment,
|
|
||||||
release: release,
|
|
||||||
session: sessionId,
|
|
||||||
pageView: pageViewId,
|
|
||||||
url: global.location ? global.location.href : '',
|
|
||||||
ua: global.navigator ? global.navigator.userAgent : '',
|
|
||||||
viewport: global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
|
|
||||||
nav: navType,
|
|
||||||
conn: (global.navigator && global.navigator.connection && global.navigator.connection.effectiveType) || '',
|
|
||||||
metrics: vitals
|
|
||||||
});
|
|
||||||
var url = vitalsEndpoint;
|
|
||||||
if (ingestKey) url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
|
|
||||||
// text/plain keeps it a CORS "simple" request: no preflight, so it survives unload.
|
|
||||||
var queued = false;
|
|
||||||
try {
|
|
||||||
queued =
|
|
||||||
!!(global.navigator && typeof global.navigator.sendBeacon === 'function') &&
|
|
||||||
global.navigator.sendBeacon(url, new global.Blob([body], { type: 'text/plain;charset=UTF-8' }));
|
|
||||||
} catch (e) {}
|
|
||||||
if (!queued && rawFetch) {
|
|
||||||
rawFetch.call(global, url, { method: 'POST', mode: 'cors', headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, body: body, keepalive: true }).catch(function () {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var script = global.document.createElement('script');
|
|
||||||
script.async = true;
|
|
||||||
script.src = vitalsLibUrl;
|
|
||||||
script.onload = function () {
|
|
||||||
var wv = global.webVitals;
|
|
||||||
if (!wv) return;
|
|
||||||
// INP durationThreshold stays at the library default; one listener per metric.
|
|
||||||
[wv.onLCP, wv.onINP, wv.onCLS, wv.onFCP, wv.onTTFB].forEach(function (observe) {
|
|
||||||
if (typeof observe === 'function') observe(onMetric);
|
|
||||||
});
|
|
||||||
// Registered after the library's own hidden listeners, so the
|
|
||||||
// final LCP/INP/CLS values are already collected when this runs.
|
|
||||||
global.addEventListener('visibilitychange', function () {
|
|
||||||
if (global.document.visibilityState === 'hidden') sendVitals();
|
|
||||||
});
|
|
||||||
global.addEventListener('pagehide', sendVitals);
|
|
||||||
// bfcache restore is a new page view for vitals purposes.
|
|
||||||
global.addEventListener('pageshow', function (e) {
|
|
||||||
if (e.persisted) {
|
|
||||||
pageViewId = randomHex(16);
|
|
||||||
vitals = {};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
global.document.head.appendChild(script);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTodoWidget() {
|
|
||||||
var todo = config.todo || {};
|
|
||||||
function start() {
|
|
||||||
if (!global.OtelTodo) return;
|
|
||||||
global.OtelTodo.init({
|
|
||||||
baseUrl: baseUrl,
|
|
||||||
serviceName: serviceName,
|
|
||||||
ingestKey: ingestKey,
|
|
||||||
components: todo.components || ((scriptEl && scriptEl.getAttribute('data-components')) || '').split(','),
|
|
||||||
shortcut: todo.shortcut || (scriptEl && scriptEl.getAttribute('data-shortcut')) || '',
|
|
||||||
hideButton: todo.hideButton === true || (scriptEl && scriptEl.getAttribute('data-hide-button') === 'true')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (global.OtelTodo) return start();
|
|
||||||
var s = global.document.createElement('script');
|
|
||||||
s.async = true;
|
|
||||||
s.src = todoLibUrl;
|
|
||||||
s.onload = start;
|
|
||||||
global.document.head.appendChild(s);
|
|
||||||
}
|
|
||||||
if (!excludeTodo && global.document) {
|
|
||||||
if (global.document.body) loadTodoWidget();
|
|
||||||
else global.document.addEventListener('DOMContentLoaded', loadTodoWidget);
|
|
||||||
}
|
|
||||||
|
|
||||||
// traceHeaders returns a headers object carrying a fresh (or
|
|
||||||
// continued) W3C traceparent, ready to spread into any HTTP client's
|
|
||||||
// request headers - fetch, XHR, axios, etc.
|
|
||||||
function traceHeaders(existingTraceId) {
|
|
||||||
var traceId = existingTraceId || newTraceId();
|
|
||||||
var spanId = newSpanId();
|
|
||||||
lastTraceId = traceId;
|
|
||||||
return { traceparent: '00-' + traceId + '-' + spanId + '-01' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureException(err, extra) {
|
|
||||||
var message = (err && err.message) || String(err);
|
|
||||||
send(
|
|
||||||
'ERROR',
|
|
||||||
message,
|
|
||||||
Object.assign(
|
|
||||||
{
|
|
||||||
'exception.type': (err && err.name) || 'Error',
|
|
||||||
'exception.message': message,
|
|
||||||
'exception.stacktrace': (err && err.stack) || ''
|
|
||||||
},
|
|
||||||
extra || {}
|
|
||||||
),
|
|
||||||
lastTraceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureMessage(message, severity, extra) {
|
|
||||||
send(severity || 'ERROR', message, extra, lastTraceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setContext(context) {
|
|
||||||
// Attach stable context (e.g. { user: 'u-123', plan: 'pro' }) to
|
|
||||||
// every subsequent event. Values become OTLP attributes prefixed
|
|
||||||
// with "context.".
|
|
||||||
contextAttrs = Object.assign({}, contextAttrs, context || {});
|
|
||||||
flush(); // ensure earlier queued events don't mix contexts ambiguously
|
|
||||||
}
|
|
||||||
var contextAttrs = {};
|
|
||||||
|
|
||||||
function onError(event) {
|
|
||||||
// Resource load errors (img/script/link) surface as an ErrorEvent-less
|
|
||||||
// Event targeting the failed element; script errors carry a message.
|
|
||||||
var target = event.target;
|
|
||||||
if (target && target !== global && target.tagName) {
|
|
||||||
var tag = String(target.tagName).toLowerCase();
|
|
||||||
if (tag === 'img' || tag === 'script' || tag === 'link') {
|
|
||||||
var url = target.src || target.href || '';
|
|
||||||
captureMessage('Failed to load ' + tag + ': ' + url, 'ERROR', {
|
|
||||||
'exception.type': 'ResourceLoadError',
|
|
||||||
'resource.tag': tag,
|
|
||||||
'resource.url': url
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var message = event.message || 'Script error';
|
|
||||||
captureException(event.error || { name: 'Error', message: message }, {
|
|
||||||
'source.file': event.filename || '',
|
|
||||||
'source.line': event.lineno,
|
|
||||||
'source.column': event.colno
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUnhandledRejection(event) {
|
|
||||||
var reason = event.reason;
|
|
||||||
captureException(reason instanceof Error ? reason : { name: 'UnhandledRejection', message: String(reason) }, {
|
|
||||||
'exception.type': (reason && reason.name) || 'UnhandledRejection'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
global.addEventListener('error', onError);
|
|
||||||
// Resource load errors don't bubble - must listen on the capturing phase.
|
|
||||||
global.addEventListener('error', onError, true);
|
|
||||||
global.addEventListener('unhandledrejection', onUnhandledRejection);
|
|
||||||
|
|
||||||
if (captureConsoleErrors && global.console && typeof global.console.error === 'function') {
|
|
||||||
var originalConsoleError = global.console.error;
|
|
||||||
global.console.error = function () {
|
|
||||||
try {
|
|
||||||
addBreadcrumb('console.error', Array.prototype.slice.call(arguments).join(' '));
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return originalConsoleError.apply(global.console, arguments);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldInject(url) {
|
|
||||||
if (!injectTraceHeaders) return false;
|
|
||||||
if (!traceHeaderOrigins) return isSameOrigin(url);
|
|
||||||
return traceHeaderOrigins.some(function (o) {
|
|
||||||
return url.indexOf(o) === 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpFailureReport reports a failed HTTP round-trip as an ERROR event,
|
|
||||||
// so backend 5xx/4xx responses show up in the Issues view even when
|
|
||||||
// the app code swallows them.
|
|
||||||
function httpFailureReport(kind, method, url, status, traceId, extra) {
|
|
||||||
// Issue refresh failures must not create new issues notifications:
|
|
||||||
// otherwise an expired session can form an API-error feedback loop.
|
|
||||||
try {
|
|
||||||
if (new global.URL(url, global.location.href).pathname === '/api/issues') return;
|
|
||||||
} catch (e) {}
|
|
||||||
var attrs = Object.assign(
|
|
||||||
{
|
|
||||||
'exception.type': 'HttpFailure',
|
|
||||||
'http.request.method': method,
|
|
||||||
'url.full': String(url).slice(0, 500),
|
|
||||||
'http.response.status_code': status === undefined || status === 0 ? '' : String(status)
|
|
||||||
},
|
|
||||||
extra || {}
|
|
||||||
);
|
|
||||||
captureMessage(
|
|
||||||
method + ' ' + url + ' failed with ' + (status === undefined || status === 0 ? 'network error' : 'HTTP ' + status),
|
|
||||||
'ERROR',
|
|
||||||
attrs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoInstrumentFetch && typeof global.fetch === 'function') {
|
|
||||||
var originalFetch = global.fetch;
|
|
||||||
global.fetch = function (input, requestInit) {
|
|
||||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
||||||
if (isCollectorUrl(url)) {
|
|
||||||
return originalFetch.call(global, input, requestInit);
|
|
||||||
}
|
|
||||||
var method = (requestInit && requestInit.method) || (input && input.method) || 'GET';
|
|
||||||
var requestTraceId = '';
|
|
||||||
|
|
||||||
if (shouldInject(url)) {
|
|
||||||
requestInit = requestInit || {};
|
|
||||||
var headers = new global.Headers(requestInit.headers || (input && input.headers) || {});
|
|
||||||
var th = traceHeaders();
|
|
||||||
headers.set('traceparent', th.traceparent);
|
|
||||||
requestTraceId = th.traceparent.slice(3, 35); // trace id portion
|
|
||||||
requestInit = Object.assign({}, requestInit, { headers: headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
var startedAt = Date.now();
|
|
||||||
return originalFetch.call(global, input, requestInit).then(
|
|
||||||
function (res) {
|
|
||||||
addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status, { d: Date.now() - startedAt, tid: requestTraceId });
|
|
||||||
if (reportHttpFailures && res.status >= 400) {
|
|
||||||
httpFailureReport('fetch', method, url, res.status, requestTraceId);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
function (err) {
|
|
||||||
addBreadcrumb('fetch', method + ' ' + url + ' -> error', { d: Date.now() - startedAt, tid: requestTraceId });
|
|
||||||
if (reportHttpFailures) {
|
|
||||||
httpFailureReport('fetch', method, url, 0, requestTraceId);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// XHR instrumentation: trace-header injection + breadcrumbs + failure
|
|
||||||
// reporting, so legacy code paths (axios older versions, jQuery, etc.)
|
|
||||||
// get the same visibility as fetch.
|
|
||||||
if (autoInstrumentXhr && typeof global.XMLHttpRequest === 'function') {
|
|
||||||
var originalOpen = global.XMLHttpRequest.prototype.open;
|
|
||||||
var originalSend = global.XMLHttpRequest.prototype.send;
|
|
||||||
var originalSetRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader;
|
|
||||||
|
|
||||||
global.XMLHttpRequest.prototype.open = function (method, url) {
|
|
||||||
if (isCollectorUrl(url)) {
|
|
||||||
return originalOpen.apply(this, arguments);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.__rum = { method: String(method), url: String(url) };
|
|
||||||
if (shouldInject(String(url))) {
|
|
||||||
var th = traceHeaders();
|
|
||||||
this.__rum.traceId = th.traceparent.slice(3, 35);
|
|
||||||
originalSetRequestHeader.call(this, 'traceparent', th.traceparent);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return originalOpen.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
global.XMLHttpRequest.prototype.send = function () {
|
|
||||||
var xhr = this;
|
|
||||||
var info = xhr.__rum || {};
|
|
||||||
var xhrStartedAt = Date.now();
|
|
||||||
if (info.url) {
|
|
||||||
var origHandler = xhr.onreadystatechange;
|
|
||||||
xhr.onreadystatechange = function () {
|
|
||||||
try {
|
|
||||||
if (xhr.readyState === 4) {
|
|
||||||
addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status, { d: Date.now() - xhrStartedAt, tid: info.traceId });
|
|
||||||
if (reportHttpFailures && (xhr.status === 0 || xhr.status >= 400)) {
|
|
||||||
httpFailureReport('xhr', info.method, info.url, xhr.status, info.traceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
if (origHandler) return origHandler.apply(this, arguments);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return originalSend.apply(this, arguments);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// SPA route-change breadcrumbs: history.pushState/replaceState and
|
|
||||||
// popstate/hashes get recorded so an error report shows which route
|
|
||||||
// transitions preceded it.
|
|
||||||
if (trackSpaRoutes && global.history) {
|
|
||||||
function recordRoute(kind) {
|
|
||||||
addBreadcrumb('navigation', kind + ' ' + (global.location ? global.location.pathname + global.location.search : ''));
|
|
||||||
}
|
|
||||||
var originalPush = global.history.pushState;
|
|
||||||
var originalReplace = global.history.replaceState;
|
|
||||||
if (typeof originalPush === 'function') {
|
|
||||||
global.history.pushState = function () {
|
|
||||||
var result = originalPush.apply(this, arguments);
|
|
||||||
try {
|
|
||||||
recordRoute('pushState');
|
|
||||||
} catch (e) {}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (typeof originalReplace === 'function') {
|
|
||||||
global.history.replaceState = function () {
|
|
||||||
var result = originalReplace.apply(this, arguments);
|
|
||||||
try {
|
|
||||||
recordRoute('replaceState');
|
|
||||||
} catch (e) {}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
global.addEventListener('popstate', function () {
|
|
||||||
try {
|
|
||||||
recordRoute('popstate');
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush pending events when the page is being hidden or unloaded so
|
|
||||||
// errors that happen right before navigation are not lost, and emit
|
|
||||||
// the final CLS value.
|
|
||||||
global.addEventListener('pagehide', function () {
|
|
||||||
flush();
|
|
||||||
});
|
|
||||||
global.addEventListener('visibilitychange', function () {
|
|
||||||
if (global.document && global.document.visibilityState === 'hidden') {
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// First paint: report vitals after observers have a chance to buffer.
|
|
||||||
if (captureWebVitals) {
|
|
||||||
observeVitals();
|
|
||||||
}
|
|
||||||
|
|
||||||
// extra.d = request duration in ms, extra.tid = trace id we sent on the
|
|
||||||
// request. t stays the completion time; the request started at t - d.
|
|
||||||
function addBreadcrumb(category, message, extra) {
|
|
||||||
var crumb = { t: Date.now(), category: String(category), message: String(message) };
|
|
||||||
if (extra && typeof extra.d === 'number') crumb.d = extra.d;
|
|
||||||
if (extra && extra.tid) crumb.tid = String(extra.tid);
|
|
||||||
breadcrumbs.push(crumb);
|
|
||||||
if (breadcrumbs.length > maxBreadcrumbs) {
|
|
||||||
breadcrumbs.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var instance = {
|
|
||||||
captureException: captureException,
|
|
||||||
captureMessage: captureMessage,
|
|
||||||
addBreadcrumb: addBreadcrumb,
|
|
||||||
setContext: setContext,
|
|
||||||
flush: flush,
|
|
||||||
newTraceId: newTraceId,
|
|
||||||
newSpanId: newSpanId,
|
|
||||||
traceHeaders: traceHeaders
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convenience handle for manual/console verification:
|
|
||||||
// OtelRUM.last.captureMessage('test from console').
|
|
||||||
global.OtelRUM.last = instance;
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId, last: undefined };
|
|
||||||
|
|
||||||
// Auto-initialize if data attributes are declared on the <script> element
|
|
||||||
try {
|
|
||||||
if (typeof document !== 'undefined' && scriptEl && (scriptEl.getAttribute('data-auto-init') === 'true' || scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) {
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
global.OtelRUM.init();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
global.OtelRUM.init();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
})(typeof window !== 'undefined' ? window : this);
|
|
||||||
+1
-327
@@ -1,327 +1 @@
|
|||||||
/**
|
"use strict";(()=>{(function(u){"use strict";var X="https://otel.k6n.net",S=document.currentScript;function i(o){return S&&S.getAttribute(o)||""}function G(o){var k=String(o).toLowerCase().split("+"),p={key:k.pop(),alt:!1,shift:!1,ctrl:!1,meta:!1};return k.forEach(function(l){l==="alt"||l==="option"?p.alt=!0:l==="shift"?p.shift=!0:l==="ctrl"||l==="control"?p.ctrl=!0:(l==="meta"||l==="cmd")&&(p.meta=!0)}),p}function A(o={}){if(o=o||{},u.OtelTodo&&u.OtelTodo.instance)return u.OtelTodo.instance;var k=(o.baseUrl||i("data-base-url")||X).replace(/\/+$/,""),p=o.endpoint||i("data-todo-endpoint")||k+"/v1/todos",l=o.serviceName||i("data-service-name")||i("data-service")||"frontend",K=o.ingestKey||i("data-ingest-key"),h=G(o.shortcut||i("data-shortcut")||"alt+shift+t"),Y=o.hideButton||i("data-hide-button")==="true",M=o.components||i("data-components").split(",");M=M.map(function(e){return e.trim()}).filter(Boolean);var D=(o.consent||i("data-consent"))==="granted"?"granted":"denied";function N(){return o.getConsent?o.getConsent():D}function _(e){D=e==="granted"?"granted":"denied"}var q=document.createElement("style");q.textContent='.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}dialog.otel-todo *{box-sizing:border-box;font:inherit}dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}dialog.otel-todo textarea{min-height:90px;resize:vertical}dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}dialog.otel-todo .status{margin-right:auto;font-size:12px}.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}.otel-todo-ref.show{display:flex}.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}.otel-todo-ref button:hover{opacity:1}.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;background:#3b82f626;border-radius:3px;transition:all .05s linear}.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;box-shadow:0 4px 14px #0004}',document.head.appendChild(q);var a=document.createElement("dialog");a.className="otel-todo",a.setAttribute("aria-label","Add todo"),a.innerHTML='<form method="dialog"><div class="meta"></div><button type="button" class="otel-todo-pick" aria-pressed="false">\u2316 Reference an element</button><div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">\u2715</button></div><textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea><select name="component" aria-label="Component (optional)"></select><div class="row"><span class="status" role="status"></span><button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div></form>';var s=a.firstElementChild,C=s.elements.namedItem("text"),g=s.elements.namedItem("component"),H=s.querySelector(".status"),J=s.querySelector(".meta"),b=s.querySelector(".otel-todo-pick"),L=s.querySelector(".otel-todo-ref"),O=L.querySelector("span");document.body.appendChild(a);var y=null,f=null,E=!1,m=null;function W(e){if(!(e instanceof Element))return"";for(var n=[],t=e;t&&t.nodeType===1&&n.length<6;){if(t.id){n.unshift("#"+CSS.escape(t.id));break}var r=t.tagName.toLowerCase(),d=(t.getAttribute("class")||"").trim().split(/\s+/).filter(Boolean).slice(0,2);d.length&&(r+="."+d.map(function(B){return CSS.escape(B)}).join("."));var c=t.parentElement;if(c){var Z=t.tagName,U=Array.from(c.children).filter(function(B){return B.tagName===Z});U.length>1&&(r+=":nth-of-type("+(U.indexOf(t)+1)+")")}n.unshift(r),t=c}return n.join(" > ")}function $(e){var n=e.tagName.toLowerCase(),t=(e.getAttribute("aria-label")||e.textCoLine truncated
|
||||||
* otel-todo.js - file todos / issues straight from the page.
|
|
||||||
*
|
|
||||||
* Shows a small floating symbol and listens for a keyboard shortcut. Opens a
|
|
||||||
* native <dialog> in the bottom-right corner with a text box, an optional
|
|
||||||
* component picker, and an "Reference an element" toggle that lets you click
|
|
||||||
* any node on the page (with a hover outline) to attach its CSS selector and
|
|
||||||
* a short label to the note. POSTs the note (with the current path, hash and
|
|
||||||
* any picked element) to the collector's /v1/todos endpoint.
|
|
||||||
*
|
|
||||||
* <script src="https://otel.example.com/static/otel-todo.js"
|
|
||||||
* data-service-name="checkout-web"
|
|
||||||
* data-ingest-key="..."
|
|
||||||
* data-components="Header,Cart,Payment"></script>
|
|
||||||
*
|
|
||||||
* Attributes (or OtelTodo.init({...}) keys): data-base-url, data-service-name
|
|
||||||
* (the project), data-ingest-key, data-components (comma list, also
|
|
||||||
* discovered from [data-component] elements on the page), data-shortcut
|
|
||||||
* (default "alt+shift+t"), data-todo-endpoint, data-hide-button="true".
|
|
||||||
*/
|
|
||||||
(function (global) {
|
|
||||||
'use strict';
|
|
||||||
var DEFAULT_BASE_URL = 'https://otel.k6n.net';
|
|
||||||
var scriptEl = document.currentScript;
|
|
||||||
|
|
||||||
function attr(name) {
|
|
||||||
return (scriptEl && scriptEl.getAttribute(name)) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseShortcut(s) {
|
|
||||||
var parts = String(s).toLowerCase().split('+');
|
|
||||||
var sc = { key: parts.pop(), alt: false, shift: false, ctrl: false, meta: false };
|
|
||||||
parts.forEach(function (p) {
|
|
||||||
if (p === 'alt' || p === 'option') sc.alt = true;
|
|
||||||
else if (p === 'shift') sc.shift = true;
|
|
||||||
else if (p === 'ctrl' || p === 'control') sc.ctrl = true;
|
|
||||||
else if (p === 'meta' || p === 'cmd') sc.meta = true;
|
|
||||||
});
|
|
||||||
return sc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(config) {
|
|
||||||
config = config || {};
|
|
||||||
if (global.OtelTodo && global.OtelTodo.instance) return global.OtelTodo.instance;
|
|
||||||
|
|
||||||
var baseUrl = (config.baseUrl || attr('data-base-url') || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
||||||
var endpoint = config.endpoint || attr('data-todo-endpoint') || baseUrl + '/v1/todos';
|
|
||||||
var project = config.serviceName || attr('data-service-name') || attr('data-service') || 'frontend';
|
|
||||||
var ingestKey = config.ingestKey || attr('data-ingest-key');
|
|
||||||
var shortcut = parseShortcut(config.shortcut || attr('data-shortcut') || 'alt+shift+t');
|
|
||||||
var hideButton = config.hideButton || attr('data-hide-button') === 'true';
|
|
||||||
var staticComponents = config.components || attr('data-components').split(',');
|
|
||||||
staticComponents = staticComponents.map(function (c) { return c.trim(); }).filter(Boolean);
|
|
||||||
|
|
||||||
var sessionId = '';
|
|
||||||
try { sessionId = global.sessionStorage.getItem('otel-rum-session') || ''; } catch (e) {}
|
|
||||||
|
|
||||||
var css = document.createElement('style');
|
|
||||||
css.textContent =
|
|
||||||
'.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;' +
|
|
||||||
'border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}' +
|
|
||||||
'.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}' +
|
|
||||||
'dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;' +
|
|
||||||
'border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}' +
|
|
||||||
'dialog.otel-todo *{box-sizing:border-box;font:inherit}' +
|
|
||||||
'dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}' +
|
|
||||||
'dialog.otel-todo textarea{min-height:90px;resize:vertical}' +
|
|
||||||
'dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}' +
|
|
||||||
'dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
||||||
'dialog.otel-todo .status{margin-right:auto;font-size:12px}' +
|
|
||||||
'.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;' +
|
|
||||||
'background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}' +
|
|
||||||
'.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}' +
|
|
||||||
'.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}' +
|
|
||||||
'.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;' +
|
|
||||||
'border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}' +
|
|
||||||
'.otel-todo-ref.show{display:flex}' +
|
|
||||||
'.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
||||||
'.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}' +
|
|
||||||
'.otel-todo-ref button:hover{opacity:1}' +
|
|
||||||
'.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;' +
|
|
||||||
'background:#3b82f626;border-radius:3px;transition:all .05s linear}' +
|
|
||||||
'.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;' +
|
|
||||||
'background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;' +
|
|
||||||
'box-shadow:0 4px 14px #0004}';
|
|
||||||
document.head.appendChild(css);
|
|
||||||
|
|
||||||
var dlg = document.createElement('dialog');
|
|
||||||
dlg.className = 'otel-todo';
|
|
||||||
dlg.setAttribute('aria-label', 'Add todo');
|
|
||||||
dlg.innerHTML =
|
|
||||||
'<form method="dialog">' +
|
|
||||||
'<div class="meta"></div>' +
|
|
||||||
'<button type="button" class="otel-todo-pick" aria-pressed="false">⌖ Reference an element</button>' +
|
|
||||||
'<div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">✕</button></div>' +
|
|
||||||
'<textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea>' +
|
|
||||||
'<select name="component" aria-label="Component (optional)"></select>' +
|
|
||||||
'<div class="row"><span class="status" role="status"></span>' +
|
|
||||||
'<button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div>' +
|
|
||||||
'</form>';
|
|
||||||
var form = dlg.firstChild;
|
|
||||||
var textEl = form.elements.text;
|
|
||||||
var selectEl = form.elements.component;
|
|
||||||
var statusEl = form.querySelector('.status');
|
|
||||||
var metaEl = form.querySelector('.meta');
|
|
||||||
var pickBtn = form.querySelector('.otel-todo-pick');
|
|
||||||
var refEl = form.querySelector('.otel-todo-ref');
|
|
||||||
var refLabelEl = refEl.querySelector('span');
|
|
||||||
document.body.appendChild(dlg);
|
|
||||||
|
|
||||||
// --- element picker: click a node on the page to attach its selector ---
|
|
||||||
var picked = null; // { selector, label }
|
|
||||||
var hoverBox = null;
|
|
||||||
var picking = false;
|
|
||||||
var hint = null;
|
|
||||||
|
|
||||||
function cssPath(el) {
|
|
||||||
if (!(el instanceof Element)) return '';
|
|
||||||
var parts = [];
|
|
||||||
var node = el;
|
|
||||||
while (node && node.nodeType === 1 && parts.length < 6) {
|
|
||||||
if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
|
|
||||||
var part = node.tagName.toLowerCase();
|
|
||||||
var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
|
||||||
if (cls.length) part += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.');
|
|
||||||
var parent = node.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
var siblings = [].filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
|
|
||||||
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
||||||
}
|
|
||||||
parts.unshift(part);
|
|
||||||
node = parent;
|
|
||||||
}
|
|
||||||
return parts.join(' > ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeElement(el) {
|
|
||||||
var tag = el.tagName.toLowerCase();
|
|
||||||
var text = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
||||||
if (text.length > 60) text = text.slice(0, 60) + '…';
|
|
||||||
return { selector: cssPath(el), label: text ? tag + ' – "' + text + '"' : tag };
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRef(ref) {
|
|
||||||
picked = ref;
|
|
||||||
if (ref) {
|
|
||||||
refLabelEl.textContent = ref.label;
|
|
||||||
refLabelEl.title = ref.selector;
|
|
||||||
refEl.classList.add('show');
|
|
||||||
} else {
|
|
||||||
refEl.classList.remove('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureHoverBox() {
|
|
||||||
if (!hoverBox) {
|
|
||||||
hoverBox = document.createElement('div');
|
|
||||||
hoverBox.className = 'otel-todo-hover-outline';
|
|
||||||
document.body.appendChild(hoverBox);
|
|
||||||
}
|
|
||||||
return hoverBox;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPickMove(e) {
|
|
||||||
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
||||||
if (!el || dlg.contains(el)) { ensureHoverBox().style.display = 'none'; return; }
|
|
||||||
var r = el.getBoundingClientRect();
|
|
||||||
var box = ensureHoverBox();
|
|
||||||
box.style.display = 'block';
|
|
||||||
box.style.left = r.left + 'px';
|
|
||||||
box.style.top = r.top + 'px';
|
|
||||||
box.style.width = r.width + 'px';
|
|
||||||
box.style.height = r.height + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPickClick(e) {
|
|
||||||
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
||||||
if (!el || dlg.contains(el)) return; // let dialog controls work normally
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setRef(describeElement(el));
|
|
||||||
stopPicking();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPickKey(e) {
|
|
||||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPicking(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPicking() {
|
|
||||||
if (picking) return;
|
|
||||||
picking = true;
|
|
||||||
pickBtn.setAttribute('aria-pressed', 'true');
|
|
||||||
pickBtn.textContent = '⌖ Click an element… (Esc to cancel)';
|
|
||||||
hint = document.createElement('div');
|
|
||||||
hint.className = 'otel-todo-pick-hint';
|
|
||||||
hint.textContent = 'Click the element you mean — Esc to cancel';
|
|
||||||
document.body.appendChild(hint);
|
|
||||||
document.addEventListener('mousemove', onPickMove, true);
|
|
||||||
document.addEventListener('click', onPickClick, true);
|
|
||||||
document.addEventListener('keydown', onPickKey, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPicking() {
|
|
||||||
if (!picking) return;
|
|
||||||
picking = false;
|
|
||||||
pickBtn.setAttribute('aria-pressed', 'false');
|
|
||||||
pickBtn.textContent = '⌖ Reference an element';
|
|
||||||
document.removeEventListener('mousemove', onPickMove, true);
|
|
||||||
document.removeEventListener('click', onPickClick, true);
|
|
||||||
document.removeEventListener('keydown', onPickKey, true);
|
|
||||||
if (hoverBox) { hoverBox.style.display = 'none'; }
|
|
||||||
if (hint) { hint.remove(); hint = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
pickBtn.addEventListener('click', function () { picking ? stopPicking() : startPicking(); });
|
|
||||||
refEl.querySelector('[data-clear-ref]').addEventListener('click', function () { setRef(null); });
|
|
||||||
|
|
||||||
function components() {
|
|
||||||
var seen = {};
|
|
||||||
var list = [];
|
|
||||||
staticComponents.concat([].map.call(document.querySelectorAll('[data-component]'), function (el) {
|
|
||||||
return el.getAttribute('data-component');
|
|
||||||
})).forEach(function (c) {
|
|
||||||
if (c && !seen[c]) { seen[c] = true; list.push(c); }
|
|
||||||
});
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
if (dlg.open) { textEl.focus(); return; }
|
|
||||||
selectEl.innerHTML = '';
|
|
||||||
var none = document.createElement('option');
|
|
||||||
none.value = '';
|
|
||||||
none.textContent = 'No component';
|
|
||||||
selectEl.appendChild(none);
|
|
||||||
var list = components();
|
|
||||||
list.forEach(function (c) {
|
|
||||||
var o = document.createElement('option');
|
|
||||||
o.value = o.textContent = c;
|
|
||||||
selectEl.appendChild(o);
|
|
||||||
});
|
|
||||||
selectEl.hidden = list.length === 0;
|
|
||||||
metaEl.textContent = project + ' · ' + location.pathname + location.hash;
|
|
||||||
statusEl.textContent = '';
|
|
||||||
setRef(null);
|
|
||||||
// Non-modal so the page stays usable while the note is being written.
|
|
||||||
dlg.show();
|
|
||||||
textEl.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
stopPicking();
|
|
||||||
if (dlg.open) dlg.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
form.querySelector('[data-cancel]').addEventListener('click', close);
|
|
||||||
dlg.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
||||||
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
|
|
||||||
});
|
|
||||||
form.addEventListener('submit', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var text = textEl.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
var headers = { 'Content-Type': 'application/json' };
|
|
||||||
var url = endpoint;
|
|
||||||
if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey;
|
|
||||||
statusEl.textContent = 'Sending…';
|
|
||||||
global.fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
mode: 'cors',
|
|
||||||
headers: headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
project: project,
|
|
||||||
text: text,
|
|
||||||
component: selectEl.value,
|
|
||||||
path: location.pathname,
|
|
||||||
hash: location.hash,
|
|
||||||
url: location.href,
|
|
||||||
ua: navigator.userAgent,
|
|
||||||
session: sessionId,
|
|
||||||
elementSelector: picked ? picked.selector : '',
|
|
||||||
elementLabel: picked ? picked.label : ''
|
|
||||||
})
|
|
||||||
}).then(function (res) {
|
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
||||||
textEl.value = '';
|
|
||||||
setRef(null);
|
|
||||||
close();
|
|
||||||
}).catch(function (err) {
|
|
||||||
statusEl.textContent = 'Failed: ' + err.message;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
global.addEventListener('keydown', function (e) {
|
|
||||||
var k = e.key.toLowerCase();
|
|
||||||
var code = (e.code || '').replace(/^(Key|Digit)/, '').toLowerCase();
|
|
||||||
if ((k === shortcut.key || code === shortcut.key) && e.altKey === shortcut.alt && e.shiftKey === shortcut.shift &&
|
|
||||||
e.ctrlKey === shortcut.ctrl && e.metaKey === shortcut.meta) {
|
|
||||||
e.preventDefault();
|
|
||||||
dlg.open ? close() : open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hideButton) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'otel-todo-btn';
|
|
||||||
btn.textContent = '✎';
|
|
||||||
btn.title = 'Add todo (' + (config.shortcut || attr('data-shortcut') || 'alt+shift+t') + ')';
|
|
||||||
btn.setAttribute('aria-label', 'Add todo');
|
|
||||||
btn.addEventListener('click', function () { dlg.open ? close() : open(); });
|
|
||||||
document.body.appendChild(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
var instance = { open: open, close: close };
|
|
||||||
global.OtelTodo.instance = instance;
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
global.OtelTodo = { init: init, instance: undefined };
|
|
||||||
|
|
||||||
if (scriptEl && (attr('data-auto-init') === 'true' || attr('data-service-name') || attr('data-service'))) {
|
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { init(); });
|
|
||||||
else init();
|
|
||||||
}
|
|
||||||
})(window);
|
|
||||||
Reference in new issue
Block a user