update
This commit is contained in:
1 parent
0ed2935f04
commit
c8cc0fb28a
11 files changed
+602
-21
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user