update
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 2m7s

This commit is contained in:
mats committed 2026-09-21 19:38:09 +02:00
1 parent 0ed2935f04
commit c8cc0fb28a
11 files changed
+602 -21

No files matched your search

+3 -16
View File
@@ -17,22 +17,9 @@ docker build \
docker push matst80/otel-debug:latest
# Shared key that only keeps strangers from uploading source maps (it is not a
# real credential). Taken from the environment, else from the cluster secret,
# else generated and stored there so the deployment can mount it.
ensure_sourcemap_key() {
if [[ -z "${OTEL_SOURCEMAP_KEY:-}" ]]; then
OTEL_SOURCEMAP_KEY="$(kubectl get secret otel-sourcemap-key -n monitoring \
-o jsonpath='{.data.key}' 2>/dev/null | base64 -d || true)"
fi
if [[ -z "${OTEL_SOURCEMAP_KEY}" ]]; then
OTEL_SOURCEMAP_KEY="$(openssl rand -hex 24)"
fi
kubectl create secret generic otel-sourcemap-key -n monitoring \
--from-literal=key="${OTEL_SOURCEMAP_KEY}" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
export OTEL_SOURCEMAP_KEY
}
ensure_sourcemap_key
# Shared key that only keeps strangers from uploading source maps. It is not a
# secret, so it is hardcoded here and in k8s/deployment.yaml.
export OTEL_SOURCEMAP_KEY="6901e62e4468631f27f1dc85fd4231f35b5e591c61f5bcfa"
# Roll out first: the new image requires OTEL_SOURCEMAP_KEY, so the manifest has
# to be applied before uploads, or any pod restart in between rejects them.
+26
View File
@@ -22,6 +22,7 @@ import (
"git.tornberg.me/go-otel/internal/prometheus"
"git.tornberg.me/go-otel/internal/sourcemaps"
"git.tornberg.me/go-otel/internal/storage"
"git.tornberg.me/go-otel/internal/todos"
"git.tornberg.me/go-otel/internal/topology"
"git.tornberg.me/go-otel/internal/websocket"
"go.opentelemetry.io/collector/pdata/plog"
@@ -285,6 +286,28 @@ func main() {
investigationStore = st
}
}
var todoStore todos.Store
if chStore, ok := store.(*storage.ClickHouseStore); ok {
if st, err := todos.NewClickHouseStore(context.Background(), chStore.Conn(), chStore.Database()); err != nil {
log.Printf("todos: clickhouse unavailable, using file store: %v", err)
} else {
todoStore = st
}
}
if todoStore == nil {
path := os.Getenv("OTEL_TODOS_PATH")
if path == "" {
path = "otel-todos.jsonl"
}
if st, err := todos.NewFileStore(path); err != nil {
log.Printf("todos disabled: %v", err)
} else {
todoStore = st
}
}
if todoStore != nil {
coll.SetTodoStore(todoStore)
}
investigator := investigation.NewHandler(investigation.ConfigFromEnv(), investigationStore)
abuse := abuseipdb.NewHandler(abuseipdb.ConfigFromEnv())
@@ -324,6 +347,7 @@ func main() {
otelMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs))
otelMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs))
otelMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals))
otelMux.HandleFunc("/v1/todos", withCORS(coll.HandleTodoCreate))
otelMux.HandleFunc("/health", coll.HandleHealth)
// UI and API mux (port 8080)
@@ -338,6 +362,7 @@ func main() {
uiMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs))
uiMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs))
uiMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals))
uiMux.HandleFunc("/v1/todos", withCORS(coll.HandleTodoCreate))
uiMux.HandleFunc("/health", coll.HandleHealth)
uiMux.HandleFunc("GET /internal/honeypot/ips", honeypotTracker.HandleIPs)
@@ -383,6 +408,7 @@ func main() {
uiMux.Handle("PATCH /api/issues/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueByID)))
uiMux.Handle("GET /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueSimilar)))
uiMux.Handle("POST /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSimilarityFeedback)))
uiMux.Handle("GET /api/todos", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTodoList)))
uiMux.Handle("GET /api/patterns", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandlePatterns)))
uiMux.Handle("GET /api/topology", oidcAuth.RequireAuth(http.HandlerFunc(topo.HandleTopology)))
uiMux.Handle("GET /api/rollouts", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRollouts)))
+28
View File
@@ -89,6 +89,34 @@ export class AppErrorBoundary extends React.Component<
Use the same pattern in your application, including a user-visible fallback
for individual views and the application root.
## Todo widget
`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
from the page, with an optional component picker. The current path and hash
are posted with it.
`otel-rum.js` loads it automatically (from the same directory as the RUM
script) and reuses its service name, base URL and ingest key. Opt out with
`OtelRUM.init({ excludeTodo: true })` or `data-exclude-todo="true"`. Or load it
standalone:
```html
<script src="https://otel.example.com/static/otel-todo.js"
data-service-name="checkout-web"
data-ingest-key="..."
data-components="Header,Cart,Payment"></script>
```
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`.
- `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
Todos go to ClickHouse when configured, else to the JSONL file at `OTEL_TODOS_PATH`
(default `otel-todos.jsonl`).
## Backend instrumentation
Instrument backend services with an OpenTelemetry SDK or auto-instrumentation
+2
View File
@@ -18,6 +18,7 @@ import (
"git.tornberg.me/go-otel/internal/prometheus"
"git.tornberg.me/go-otel/internal/sourcemaps"
"git.tornberg.me/go-otel/internal/storage"
"git.tornberg.me/go-otel/internal/todos"
"git.tornberg.me/go-otel/internal/uaclass"
"git.tornberg.me/go-otel/internal/websocket"
@@ -43,6 +44,7 @@ type Collector struct {
ingestLimiter *ipRateLimiter
ingestKey string
todoStore todos.Store
sourceMapKey string
notifyIssuesFn func()
+118
View File
@@ -0,0 +1,118 @@
package collector
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"time"
"git.tornberg.me/go-otel/internal/todos"
)
const (
maxTodoBody = 16 << 10
maxTodoText = 4000
maxTodoMeta = 500
)
// SetTodoStore enables the todo endpoints.
func (c *Collector) SetTodoStore(s todos.Store) { c.todoStore = s }
func clipTo(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n]
}
return s
}
type todoPayload struct {
Project string `json:"project"`
Text string `json:"text"`
Component string `json:"component"`
Path string `json:"path"`
Hash string `json:"hash"`
URL string `json:"url"`
UserAgent string `json:"ua"`
Session string `json:"session"`
}
// HandleTodoCreate ingests a todo posted by otel-todo.js. It is guarded by
// the same ingest key and rate limit as the other RUM endpoints.
func (c *Collector) HandleTodoCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if c.todoStore == nil {
http.Error(w, "todos not enabled", http.StatusServiceUnavailable)
return
}
if !c.checkIngest(w, r) {
return
}
var p todoPayload
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxTodoBody)).Decode(&p); err != nil {
http.Error(w, "invalid todo payload", http.StatusBadRequest)
return
}
text := clipTo(p.Text, maxTodoText)
if text == "" {
http.Error(w, "text is required", http.StatusBadRequest)
return
}
project := clipTo(p.Project, maxTodoMeta)
if project == "" {
project = "frontend"
}
id := make([]byte, 8)
_, _ = rand.Read(id)
t := todos.Todo{
ID: hex.EncodeToString(id),
Project: project,
Text: text,
Component: clipTo(p.Component, maxTodoMeta),
Path: clipTo(p.Path, maxTodoMeta),
Hash: clipTo(p.Hash, maxTodoMeta),
URL: clipTo(p.URL, maxTodoMeta),
UserAgent: clipTo(p.UserAgent, maxTodoMeta),
Session: clipTo(p.Session, maxTodoMeta),
CreatedAt: time.Now().UTC(),
}
if err := c.todoStore.Add(r.Context(), t); err != nil {
log.Printf("failed to store todo: %v", err)
http.Error(w, "failed to store todo", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(t)
}
// HandleTodoList returns todos, newest first. ?project= filters by project
// (omit for all); ?limit= defaults to 100, max 1000. Registered behind auth.
func (c *Collector) HandleTodoList(w http.ResponseWriter, r *http.Request) {
if c.todoStore == nil {
http.Error(w, "todos not enabled", http.StatusServiceUnavailable)
return
}
limit := 100
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
limit = min(v, 1000)
}
items, err := c.todoStore.List(r.Context(), r.URL.Query().Get("project"), limit)
if err != nil {
log.Printf("failed to list todos: %v", err)
http.Error(w, "failed to list todos", http.StatusInternalServerError)
return
}
if items == nil {
items = []todos.Todo{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(items)
}
+45
View File
@@ -0,0 +1,45 @@
package collector
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.tornberg.me/go-otel/internal/todos"
)
func TestTodoCreateAndList(t *testing.T) {
coll := New(nil, 10, nil)
st, err := todos.NewFileStore(filepath.Join(t.TempDir(), "todos.jsonl"))
if err != nil {
t.Fatal(err)
}
coll.SetTodoStore(st)
post := func(body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
coll.HandleTodoCreate(w, httptest.NewRequest(http.MethodPost, "/v1/todos", strings.NewReader(body)))
return w
}
if w := post(`{"project":"a","text":"fix cart","component":"Cart","path":"/cart","hash":"#x"}`); w.Code != http.StatusCreated {
t.Fatalf("create: %d %s", w.Code, w.Body)
}
post(`{"project":"b","text":"other"}`)
if w := post(`{"project":"a","text":" "}`); w.Code != http.StatusBadRequest {
t.Fatalf("empty text: %d", w.Code)
}
list := func(q string) string {
w := httptest.NewRecorder()
coll.HandleTodoList(w, httptest.NewRequest(http.MethodGet, "/api/todos"+q, nil))
return w.Body.String()
}
if got := list("?project=a"); !strings.Contains(got, "fix cart") || strings.Contains(got, "other") || !strings.Contains(got, `"hash":"#x"`) {
t.Fatalf("project filter: %s", got)
}
if got := list(""); !strings.Contains(got, "fix cart") || !strings.Contains(got, "other") {
t.Fatalf("all: %s", got)
}
}
+155
View File
@@ -0,0 +1,155 @@
// Package todos stores todo/issue notes filed from the browser by the
// otel-todo.js widget.
package todos
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"sort"
"sync"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
// Todo is one note filed from a page. Project is the RUM service name.
type Todo struct {
ID string `json:"id"`
Project string `json:"project"`
Text string `json:"text"`
Component string `json:"component,omitempty"`
Path string `json:"path,omitempty"`
Hash string `json:"hash,omitempty"`
URL string `json:"url,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
Session string `json:"session,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// Store persists todos. List returns newest first; an empty project means all.
type Store interface {
Add(ctx context.Context, t Todo) error
List(ctx context.Context, project string, limit int) ([]Todo, error)
}
// FileStore keeps todos in memory and appends them to a JSONL file.
type FileStore struct {
mu sync.Mutex
path string
items []Todo
}
func NewFileStore(path string) (*FileStore, error) {
fs := &FileStore{path: path}
f, err := os.Open(path)
if os.IsNotExist(err) {
return fs, nil
}
if err != nil {
return nil, err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64<<10), 1<<20)
for sc.Scan() {
var t Todo
if json.Unmarshal(sc.Bytes(), &t) == nil {
fs.items = append(fs.items, t)
}
}
return fs, sc.Err()
}
func (s *FileStore) Add(_ context.Context, t Todo) error {
line, err := json.Marshal(t)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(append(line, '\n')); err != nil {
return err
}
s.items = append(s.items, t)
return nil
}
func (s *FileStore) List(_ context.Context, project string, limit int) ([]Todo, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []Todo
for _, t := range s.items {
if project == "" || t.Project == project {
out = append(out, t)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
// ClickHouseStore persists todos in a plain MergeTree table (no TTL).
type ClickHouseStore struct {
conn driver.Conn
db string
}
func NewClickHouseStore(ctx context.Context, conn driver.Conn, db string) (*ClickHouseStore, error) {
err := conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.todos (
id String,
project LowCardinality(String),
text String,
component String,
path String,
hash String,
url String,
user_agent String,
session String,
created_at DateTime64(3, 'UTC')
) ENGINE = MergeTree
ORDER BY (project, created_at)`, db))
if err != nil {
return nil, err
}
return &ClickHouseStore{conn: conn, db: db}, nil
}
func (s *ClickHouseStore) Add(ctx context.Context, t Todo) error {
return s.conn.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.todos (id, project, text, component, path, hash, url, user_agent, session, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, s.db),
t.ID, t.Project, t.Text, t.Component, t.Path, t.Hash, t.URL, t.UserAgent, t.Session, t.CreatedAt.UTC())
}
func (s *ClickHouseStore) List(ctx context.Context, project string, limit int) ([]Todo, error) {
q := fmt.Sprintf(`SELECT id, project, text, component, path, hash, url, user_agent, session, created_at FROM %s.todos`, s.db)
var args []any
if project != "" {
q += ` WHERE project = ?`
args = append(args, project)
}
q += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.conn.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Todo
for rows.Next() {
var t Todo
if err := rows.Scan(&t.ID, &t.Project, &t.Text, &t.Component, &t.Path, &t.Hash, &t.URL, &t.UserAgent, &t.Session, &t.CreatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
+1 -4
View File
@@ -52,10 +52,7 @@ spec:
# Shared key required to POST /api/sourcemaps (X-Sourcemap-Key header);
# uploads are refused while it is unset.
- name: OTEL_SOURCEMAP_KEY
valueFrom:
secretKeyRef:
name: otel-sourcemap-key
key: key
value: "6901e62e4468631f27f1dc85fd4231f35b5e591c61f5bcfa"
# AbuseIPDB API key for authenticated IP checks/reports from the UI.
# Create out of band: kubectl create secret generic abuseipdb-key -n monitoring --from-literal=key=...
- name: ABUSE_KEY
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
: "${OTEL_URL:?set OTEL_URL, for example https://otel.example.com}"
: "${OTEL_SOURCEMAP_KEY:?set OTEL_SOURCEMAP_KEY to the shared upload key}"
OTEL_SOURCEMAP_KEY="${OTEL_SOURCEMAP_KEY:-6901e62e4468631f27f1dc85fd4231f35b5e591c61f5bcfa}"
: "${RELEASE:?set RELEASE to the exact VITE_APP_RELEASE used at build time}"
bundle="${1:?usage: RELEASE=... OTEL_URL=... $0 dist/assets/index-HASH.js}"
+32
View File
@@ -142,6 +142,13 @@
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.
@@ -406,6 +413,31 @@
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.
+191
View File
@@ -0,0 +1,191 @@
/**
* otel-todo.js - file todos / issues straight from the page.
*
* Shows a small floating symbol and listens for a keyboard shortcut. Either
* opens a native <dialog> in the bottom-right corner with a text box and an
* optional component picker, and POSTs the note (with the current path and
* hash) 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}';
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>' +
'<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');
document.body.appendChild(dlg);
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 = '';
// Non-modal so the page stays usable while the note is being written.
dlg.show();
textEl.focus();
}
function close() { 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
})
}).then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
textEl.value = '';
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);