hub v0.43.0: remote app-log diagnostics — copyable issues + context + on-demand log tails + range/dismissal fixes
- store: app_log_issues gains context/context_customer (first capture wins) + dismissed_at (resurface only on last_seen > dismissed_at); log_tail_requests (pending operator intents, consume-once) + app_log_tails (transient, keep last 2 per app) - api: /report ingests log_tails (stores + clears the request); ACK advertises log_tail_requests (same additive omit-when-empty pattern as escrow) - web: Known Issues rows click-to-expand (full copyable message + context with provenance + explicit affected-customers list); Dismiss replaces Delete; period selector now filters issues (F); ?customer= filtered view + customer-page drill-down links (H); per-app Request-log-tail button + pending badge + App Log Tails section + ordered tail view with line numbers + .log download; customer-visible log_tail_requested event - tests: store (context first-capture/late-adopt, range filter, dismissal old-window vs new-occurrence, tail request/fulfill/prune/scoping), api ACK round-trip, web render (expanded row, customer page sections, tail view + download + cross-customer 404) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// On-demand app log tails (Part D) — the pull-based counterpart of the controller's
|
||||
// log_tails report section. The hub stores an operator intent in log_tail_requests,
|
||||
// advertises it on the report ACK (log_tail_requests: [app…]), and when the tail lands
|
||||
// on the NEXT report it is stored here and the request is cleared (consume-once).
|
||||
// Tails are transient custody: the last 2 per (customer, app) are kept, older pruned.
|
||||
|
||||
// AppLogTail is one received, ordered log tail.
|
||||
type AppLogTail struct {
|
||||
ID int
|
||||
CustomerID string
|
||||
AppName string
|
||||
CollectedAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Lines []string
|
||||
}
|
||||
|
||||
// RequestLogTail records (or refreshes) the operator's pending request for one app's
|
||||
// log tail. One active request per (customer, app) — a re-click refreshes requested_at.
|
||||
func (s *Store) RequestLogTail(customerID, appName string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO log_tail_requests (customer_id, app_name, requested_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(customer_id, app_name) DO UPDATE SET requested_at = excluded.requested_at`,
|
||||
customerID, appName, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPendingLogTailRequests returns the app names with a pending tail request for the
|
||||
// customer — the value advertised on the report ACK. nil/empty = nothing pending.
|
||||
func (s *Store) GetPendingLogTailRequests(customerID string) ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT app_name FROM log_tail_requests WHERE customer_id = ? ORDER BY app_name`, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var apps []string
|
||||
for rows.Next() {
|
||||
var app string
|
||||
if err := rows.Scan(&app); err == nil {
|
||||
apps = append(apps, app)
|
||||
}
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
// SaveAppLogTail stores a received tail, prunes to the newest 2 per (customer, app),
|
||||
// and clears the pending request (consume-once — without the clear the ACK would keep
|
||||
// advertising the request and the controller would ship the tail every cycle).
|
||||
func (s *Store) SaveAppLogTail(customerID, appName string, collectedAt time.Time, lines []string) error {
|
||||
linesJSON, err := json.Marshal(lines)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO app_log_tails (customer_id, app_name, collected_at, received_at, lines_json)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
customerID, appName, collectedAt, time.Now().UTC(), string(linesJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the newest 2 per (customer, app).
|
||||
if _, err := tx.Exec(`
|
||||
DELETE FROM app_log_tails
|
||||
WHERE customer_id = ? AND app_name = ? AND id NOT IN (
|
||||
SELECT id FROM app_log_tails WHERE customer_id = ? AND app_name = ?
|
||||
ORDER BY id DESC LIMIT 2
|
||||
)`, customerID, appName, customerID, appName); err != nil {
|
||||
return err
|
||||
}
|
||||
// Consume-once: the fulfilled request is cleared.
|
||||
if _, err := tx.Exec(`DELETE FROM log_tail_requests WHERE customer_id = ? AND app_name = ?`,
|
||||
customerID, appName); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetCustomerLogTails returns all stored tails for a customer, newest first.
|
||||
func (s *Store) GetCustomerLogTails(customerID string) ([]AppLogTail, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, customer_id, app_name, collected_at, received_at, lines_json
|
||||
FROM app_log_tails WHERE customer_id = ?
|
||||
ORDER BY id DESC`, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var tails []AppLogTail
|
||||
for rows.Next() {
|
||||
t, err := scanLogTail(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tails = append(tails, t)
|
||||
}
|
||||
return tails, rows.Err()
|
||||
}
|
||||
|
||||
// GetLogTail returns one stored tail by id, scoped to the customer (no cross-customer reads).
|
||||
func (s *Store) GetLogTail(id int, customerID string) (*AppLogTail, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, customer_id, app_name, collected_at, received_at, lines_json
|
||||
FROM app_log_tails WHERE id = ? AND customer_id = ?`, id, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
t, err := scanLogTail(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func scanLogTail(rows *sql.Rows) (AppLogTail, error) {
|
||||
var t AppLogTail
|
||||
var linesJSON string
|
||||
if err := rows.Scan(&t.ID, &t.CustomerID, &t.AppName, &t.CollectedAt, &t.ReceivedAt, &linesJSON); err != nil {
|
||||
return t, err
|
||||
}
|
||||
json.Unmarshal([]byte(linesJSON), &t.Lines)
|
||||
return t, nil
|
||||
}
|
||||
@@ -440,6 +440,42 @@ func (s *Store) migrate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// v0.43.0 — remote app-log diagnostics. Additive columns on app_log_issues:
|
||||
// context = JSON array of ±5 redacted lines around the FIRST occurrence (first capture
|
||||
// wins — stable repro context, no churn); context_customer = whose box it came from
|
||||
// (provenance); dismissed_at = dismissal instead of futile deletion (the controller
|
||||
// re-sends recurring issues — a delete is undone minutes later; a dismissal stays until
|
||||
// a genuinely NEW occurrence with last_seen > dismissed_at resurfaces the row).
|
||||
s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN context TEXT`)
|
||||
s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN context_customer TEXT`)
|
||||
s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN dismissed_at DATETIME`)
|
||||
|
||||
// log_tail_requests: the operator's pending "send me this app's logs" intents — the
|
||||
// pull-based ACK flag (the hub NEVER connects into a box; the controller sees the flag
|
||||
// in its report ACK and ships the tail on its next cycle). One active request per
|
||||
// (customer, app) — a re-click refreshes requested_at. Cleared when the tail arrives
|
||||
// (consume-once). app_log_tails: the received tails, transient — keep the last 2 per
|
||||
// (customer, app), older pruned at insert.
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS log_tail_requests (
|
||||
customer_id TEXT NOT NULL,
|
||||
app_name TEXT NOT NULL,
|
||||
requested_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (customer_id, app_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS app_log_tails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL,
|
||||
app_name TEXT NOT NULL,
|
||||
collected_at DATETIME NOT NULL,
|
||||
received_at DATETIME NOT NULL,
|
||||
lines_json TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ type AppTelemetryRecord struct {
|
||||
Message string `json:"message"`
|
||||
Count int `json:"count"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// Context (controller v0.111.0+): ±5 redacted lines around the first occurrence.
|
||||
// Absent on older controllers — nil-safe everywhere.
|
||||
Context []string `json:"context,omitempty"`
|
||||
} `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
@@ -94,6 +97,13 @@ type AppIssue struct {
|
||||
LastSeen time.Time
|
||||
OccurrenceCount int
|
||||
AffectedCustomers []string
|
||||
// Context: redacted lines around the FIRST captured occurrence (first capture wins);
|
||||
// ContextCustomer records whose box it came from (provenance). Empty on pre-v0.111 data.
|
||||
Context []string
|
||||
ContextCustomer string
|
||||
// DismissedAt: non-nil = operator-dismissed; hidden from the default view until a
|
||||
// genuinely NEW occurrence (last_seen > dismissed_at) resurfaces it.
|
||||
DismissedAt *time.Time
|
||||
}
|
||||
|
||||
// SaveAppTelemetry inserts telemetry records for a customer report into the database.
|
||||
@@ -136,7 +146,7 @@ func (s *Store) SaveAppTelemetry(customerID string, reportedAt time.Time, record
|
||||
// Upsert log issues
|
||||
for _, issue := range r.Issues {
|
||||
fp := fingerprintIssue(issue.Message)
|
||||
if err := upsertAppIssue(tx, r.AppName, fp, issue.Severity, issue.Message, customerID, issue.LastSeen); err != nil {
|
||||
if err := upsertAppIssue(tx, r.AppName, fp, issue.Severity, issue.Message, customerID, issue.LastSeen, issue.Context); err != nil {
|
||||
s.logger.Printf("[WARN] upsertAppIssue %s/%s: %v", r.AppName, fp[:min(len(fp), 20)], err)
|
||||
}
|
||||
}
|
||||
@@ -146,15 +156,32 @@ func (s *Store) SaveAppTelemetry(customerID string, reportedAt time.Time, record
|
||||
}
|
||||
|
||||
// upsertAppIssue inserts or updates a known log issue record.
|
||||
func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, customerID string, lastSeen time.Time) error {
|
||||
//
|
||||
// Context: stored on INSERT; on conflict only adopted when the stored context is still
|
||||
// empty (first capture wins — stable repro context, no churn). context_customer moves
|
||||
// with it (provenance).
|
||||
//
|
||||
// Dismissal: a dismissed row is resurrected ONLY by a genuinely NEW occurrence
|
||||
// (excluded.last_seen > dismissed_at) — a re-sent old window stays hidden, but a
|
||||
// recurrence is never silently swallowed.
|
||||
func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, customerID string, lastSeen time.Time, context []string) error {
|
||||
contextJSON := ""
|
||||
if len(context) > 0 {
|
||||
if b, err := json.Marshal(context); err == nil {
|
||||
contextJSON = string(b)
|
||||
}
|
||||
}
|
||||
// Try insert first
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
||||
INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers, context, context_customer)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(app_name, fingerprint) DO UPDATE SET
|
||||
last_seen = CASE WHEN excluded.last_seen > last_seen THEN excluded.last_seen ELSE last_seen END,
|
||||
occurrence_count = occurrence_count + 1`,
|
||||
appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`)
|
||||
occurrence_count = occurrence_count + 1,
|
||||
dismissed_at = CASE WHEN dismissed_at IS NOT NULL AND excluded.last_seen > dismissed_at THEN NULL ELSE dismissed_at END,
|
||||
context_customer = CASE WHEN (context IS NULL OR context = '') AND excluded.context != '' THEN excluded.context_customer ELSE context_customer END,
|
||||
context = CASE WHEN (context IS NULL OR context = '') AND excluded.context != '' THEN excluded.context ELSE context END`,
|
||||
appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`, contextJSON, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -394,15 +421,23 @@ func (s *Store) GetCustomerAppSummary(customerID string, since time.Time) ([]Cus
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAppIssues returns recent known issues for a specific app.
|
||||
func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) {
|
||||
rows, err := s.db.Query(`
|
||||
// GetAppIssues returns known issues for a specific app seen since the given time.
|
||||
// The time filter is the SAME period the memory-trend chart uses (Part F fix: the
|
||||
// 24h/7d/30d selector now applies to issues too). Dismissed rows are excluded unless
|
||||
// includeDismissed is set.
|
||||
func (s *Store) GetAppIssues(appName string, since time.Time, includeDismissed bool, limit int) ([]AppIssue, error) {
|
||||
q := `
|
||||
SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen,
|
||||
occurrence_count, affected_customers
|
||||
occurrence_count, affected_customers, context, context_customer, dismissed_at
|
||||
FROM app_log_issues
|
||||
WHERE app_name = ?
|
||||
WHERE app_name = ? AND last_seen >= ?`
|
||||
if !includeDismissed {
|
||||
q += ` AND dismissed_at IS NULL`
|
||||
}
|
||||
q += `
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT ?`, appName, limit)
|
||||
LIMIT ?`
|
||||
rows, err := s.db.Query(q, appName, since, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -411,12 +446,13 @@ func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) {
|
||||
return scanAppIssues(rows)
|
||||
}
|
||||
|
||||
// GetRecentIssuesAllApps returns the most recently seen issues across all apps.
|
||||
// GetRecentIssuesAllApps returns the most recently seen non-dismissed issues across all apps.
|
||||
func (s *Store) GetRecentIssuesAllApps(limit int) ([]AppIssue, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen,
|
||||
occurrence_count, affected_customers
|
||||
occurrence_count, affected_customers, context, context_customer, dismissed_at
|
||||
FROM app_log_issues
|
||||
WHERE dismissed_at IS NULL
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
@@ -432,16 +468,61 @@ func scanAppIssues(rows *sql.Rows) ([]AppIssue, error) {
|
||||
for rows.Next() {
|
||||
var ai AppIssue
|
||||
var affectedJSON string
|
||||
var contextJSON, contextCustomer sql.NullString
|
||||
var dismissedAt sql.NullTime
|
||||
if err := rows.Scan(&ai.ID, &ai.AppName, &ai.Fingerprint, &ai.Severity, &ai.Message,
|
||||
&ai.FirstSeen, &ai.LastSeen, &ai.OccurrenceCount, &affectedJSON); err != nil {
|
||||
&ai.FirstSeen, &ai.LastSeen, &ai.OccurrenceCount, &affectedJSON,
|
||||
&contextJSON, &contextCustomer, &dismissedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal([]byte(affectedJSON), &ai.AffectedCustomers)
|
||||
if contextJSON.Valid && contextJSON.String != "" {
|
||||
json.Unmarshal([]byte(contextJSON.String), &ai.Context)
|
||||
}
|
||||
if contextCustomer.Valid {
|
||||
ai.ContextCustomer = contextCustomer.String
|
||||
}
|
||||
if dismissedAt.Valid {
|
||||
t := dismissedAt.Time
|
||||
ai.DismissedAt = &t
|
||||
}
|
||||
issues = append(issues, ai)
|
||||
}
|
||||
return issues, rows.Err()
|
||||
}
|
||||
|
||||
// DismissAppIssues marks all non-dismissed issues of an app dismissed (Part G: dismissal
|
||||
// over deletion — the controller re-sends recurring issues, so a delete is futile; a
|
||||
// dismissed row stays hidden until a NEW occurrence resurfaces it via upsertAppIssue).
|
||||
func (s *Store) DismissAppIssues(appName string) (int64, error) {
|
||||
res, err := s.db.Exec(`UPDATE app_log_issues SET dismissed_at = ? WHERE app_name = ? AND dismissed_at IS NULL`,
|
||||
time.Now().UTC(), appName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// DismissAppIssuesByIDs marks specific issues dismissed by their IDs.
|
||||
func (s *Store) DismissAppIssuesByIDs(ids []int) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]interface{}, 0, len(ids)+1)
|
||||
args = append(args, time.Now().UTC())
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
query := "UPDATE app_log_issues SET dismissed_at = ? WHERE id IN (" + strings.Join(placeholders, ",") + ")"
|
||||
res, err := s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// PruneAppTelemetry removes telemetry rows older than the given time.
|
||||
func (s *Store) PruneAppTelemetry(before time.Time) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM app_telemetry WHERE reported_at < ?", before)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTelemetryTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(filepath.Join(t.TempDir(), "telemetry.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
// mkTelemetryRecord builds an AppTelemetryRecord through JSON — the same path a real
|
||||
// controller report takes (also proves nil-safety when context is absent entirely).
|
||||
func mkTelemetryRecord(t *testing.T, appName, severity, message string, lastSeen time.Time, context []string) AppTelemetryRecord {
|
||||
t.Helper()
|
||||
issue := map[string]interface{}{
|
||||
"severity": severity,
|
||||
"message": message,
|
||||
"count": 1,
|
||||
"last_seen": lastSeen.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if context != nil {
|
||||
issue["context"] = context
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"app_name": appName,
|
||||
"issues": []interface{}{issue},
|
||||
})
|
||||
var rec AppTelemetryRecord
|
||||
if err := json.Unmarshal(b, &rec); err != nil {
|
||||
t.Fatalf("unmarshal record: %v", err)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func saveIssue(t *testing.T, s *Store, customer, app, severity, msg string, lastSeen time.Time, context []string) {
|
||||
t.Helper()
|
||||
rec := mkTelemetryRecord(t, app, severity, msg, lastSeen, context)
|
||||
if err := s.SaveAppTelemetry(customer, time.Now(), []AppTelemetryRecord{rec}); err != nil {
|
||||
t.Fatalf("SaveAppTelemetry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getOneIssue(t *testing.T, s *Store, app string, since time.Time, includeDismissed bool) *AppIssue {
|
||||
t.Helper()
|
||||
issues, err := s.GetAppIssues(app, since, includeDismissed, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAppIssues: %v", err)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &issues[0]
|
||||
}
|
||||
|
||||
var longAgo = time.Now().Add(-365 * 24 * time.Hour)
|
||||
|
||||
// Part C — context stored on INSERT with provenance; a later empty-context upsert does
|
||||
// not clobber; a later DIFFERENT context does not churn it (first capture wins).
|
||||
func TestUpsertIssue_ContextFirstCaptureWins(t *testing.T) {
|
||||
s := newTelemetryTestStore(t)
|
||||
msg := "ERROR: nfs mount /mnt/media lost"
|
||||
now := time.Now()
|
||||
|
||||
saveIssue(t, s, "cust-a", "cwa", "error", msg, now, []string{"before line", "ERROR: nfs mount /mnt/media lost", "after line"})
|
||||
is := getOneIssue(t, s, "cwa", longAgo, false)
|
||||
if is == nil {
|
||||
t.Fatalf("issue not stored")
|
||||
}
|
||||
if len(is.Context) != 3 || is.Context[0] != "before line" {
|
||||
t.Fatalf("context not stored on insert: %#v", is.Context)
|
||||
}
|
||||
if is.ContextCustomer != "cust-a" {
|
||||
t.Fatalf("provenance = %q, want cust-a", is.ContextCustomer)
|
||||
}
|
||||
|
||||
// A pre-v0.111 controller (no context field) re-reports the same issue: must not clobber.
|
||||
saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(time.Minute), nil)
|
||||
is = getOneIssue(t, s, "cwa", longAgo, false)
|
||||
if len(is.Context) != 3 {
|
||||
t.Fatalf("empty-context upsert clobbered the stored context: %#v", is.Context)
|
||||
}
|
||||
if is.ContextCustomer != "cust-a" {
|
||||
t.Fatalf("provenance churned to %q", is.ContextCustomer)
|
||||
}
|
||||
// both customers recorded
|
||||
if len(is.AffectedCustomers) != 2 {
|
||||
t.Fatalf("affected customers = %v, want both", is.AffectedCustomers)
|
||||
}
|
||||
|
||||
// A different context from another box: first capture still wins (no churn).
|
||||
saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(2*time.Minute), []string{"other box context"})
|
||||
is = getOneIssue(t, s, "cwa", longAgo, false)
|
||||
if is.Context[0] != "before line" || is.ContextCustomer != "cust-a" {
|
||||
t.Fatalf("first-capture-wins violated: ctx=%#v from %q", is.Context, is.ContextCustomer)
|
||||
}
|
||||
}
|
||||
|
||||
// Part C — an issue first seen WITHOUT context (old controller) adopts the first
|
||||
// context that arrives later.
|
||||
func TestUpsertIssue_LateContextAdopted(t *testing.T) {
|
||||
s := newTelemetryTestStore(t)
|
||||
msg := "ERROR: db locked"
|
||||
saveIssue(t, s, "cust-a", "gokapi", "error", msg, time.Now(), nil)
|
||||
is := getOneIssue(t, s, "gokapi", longAgo, false)
|
||||
if len(is.Context) != 0 {
|
||||
t.Fatalf("expected no context yet, got %#v", is.Context)
|
||||
}
|
||||
saveIssue(t, s, "cust-b", "gokapi", "error", msg, time.Now().Add(time.Minute), []string{"ctx line"})
|
||||
is = getOneIssue(t, s, "gokapi", longAgo, false)
|
||||
if len(is.Context) != 1 || is.Context[0] != "ctx line" || is.ContextCustomer != "cust-b" {
|
||||
t.Fatalf("late context not adopted: %#v from %q", is.Context, is.ContextCustomer)
|
||||
}
|
||||
}
|
||||
|
||||
// Part F — the time-range filter: a 10-day-old issue is absent from the 24h view,
|
||||
// present in the 30d view. Red-proof target: drop the last_seen >= since predicate →
|
||||
// the 24h view shows the old row → this fails.
|
||||
func TestGetAppIssues_RangeFilter(t *testing.T) {
|
||||
s := newTelemetryTestStore(t)
|
||||
tenDaysAgo := time.Now().Add(-10 * 24 * time.Hour)
|
||||
saveIssue(t, s, "cust-a", "vault", "error", "ERROR: smtp down", tenDaysAgo, nil)
|
||||
|
||||
if got := getOneIssue(t, s, "vault", time.Now().Add(-24*time.Hour), false); got != nil {
|
||||
t.Fatalf("10d-old issue visible in the 24h view (last_seen=%s)", got.LastSeen)
|
||||
}
|
||||
if got := getOneIssue(t, s, "vault", time.Now().Add(-30*24*time.Hour), false); got == nil {
|
||||
t.Fatalf("10d-old issue missing from the 30d view")
|
||||
}
|
||||
}
|
||||
|
||||
// Part G — dismissal semantics: a re-sent OLD window (last_seen <= dismissed_at) stays
|
||||
// hidden; a genuinely NEW occurrence (last_seen > dismissed_at) resurfaces the row.
|
||||
// Red-proof: drop the `excluded.last_seen > dismissed_at` guard in upsertAppIssue (make
|
||||
// the conflict always clear dismissed_at) → the old-window re-report resurrects → FAIL.
|
||||
func TestDismiss_OldWindowHidden_NewOccurrenceResurfaces(t *testing.T) {
|
||||
s := newTelemetryTestStore(t)
|
||||
msg := "ERROR: cwa nfs stale handle"
|
||||
oldSeen := time.Now().Add(-time.Hour)
|
||||
|
||||
saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil)
|
||||
if n, err := s.DismissAppIssues("cwa"); err != nil || n != 1 {
|
||||
t.Fatalf("DismissAppIssues = %d/%v, want 1", n, err)
|
||||
}
|
||||
if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil {
|
||||
t.Fatalf("dismissed issue still in the default view")
|
||||
}
|
||||
// visible with the toggle, flagged
|
||||
if got := getOneIssue(t, s, "cwa", longAgo, true); got == nil || got.DismissedAt == nil {
|
||||
t.Fatalf("show-dismissed view must expose the row with DismissedAt set")
|
||||
}
|
||||
|
||||
// Old-window re-report (controller re-sends its rolling window with the SAME last_seen).
|
||||
saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil)
|
||||
if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil {
|
||||
t.Fatalf("old-window re-report RESURRECTED the dismissed issue (last_seen=%s)", got.LastSeen)
|
||||
}
|
||||
|
||||
// A genuinely NEW occurrence — recurrence must never be silently swallowed.
|
||||
newSeen := time.Now().Add(time.Minute)
|
||||
saveIssue(t, s, "cust-a", "cwa", "error", msg, newSeen, nil)
|
||||
got := getOneIssue(t, s, "cwa", longAgo, false)
|
||||
if got == nil {
|
||||
t.Fatalf("NEW occurrence did not resurface the dismissed issue")
|
||||
}
|
||||
if got.DismissedAt != nil {
|
||||
t.Fatalf("resurfaced issue still flagged dismissed")
|
||||
}
|
||||
}
|
||||
|
||||
// Part D (store half) — request → pending; tail arrival stores, prunes to 2, and clears
|
||||
// the request (consume-once). Red-proof: remove the request-DELETE in SaveAppLogTail →
|
||||
// the pending request survives fulfillment → this fails.
|
||||
func TestLogTail_RequestFulfillConsumeOnce(t *testing.T) {
|
||||
s := newTelemetryTestStore(t)
|
||||
|
||||
if err := s.RequestLogTail("cust-a", "gokapi"); err != nil {
|
||||
t.Fatalf("RequestLogTail: %v", err)
|
||||
}
|
||||
// re-click refreshes, still one request
|
||||
if err := s.RequestLogTail("cust-a", "gokapi"); err != nil {
|
||||
t.Fatalf("RequestLogTail re-click: %v", err)
|
||||
}
|
||||
apps, err := s.GetPendingLogTailRequests("cust-a")
|
||||
if err != nil || len(apps) != 1 || apps[0] != "gokapi" {
|
||||
t.Fatalf("pending = %v/%v, want [gokapi]", apps, err)
|
||||
}
|
||||
// another customer sees nothing
|
||||
if other, _ := s.GetPendingLogTailRequests("cust-b"); len(other) != 0 {
|
||||
t.Fatalf("cross-customer pending leak: %v", other)
|
||||
}
|
||||
|
||||
lines := []string{"first line", "second line", "third line"}
|
||||
if err := s.SaveAppLogTail("cust-a", "gokapi", time.Now(), lines); err != nil {
|
||||
t.Fatalf("SaveAppLogTail: %v", err)
|
||||
}
|
||||
// consume-once: fulfilled request cleared
|
||||
if apps, _ := s.GetPendingLogTailRequests("cust-a"); len(apps) != 0 {
|
||||
t.Fatalf("request survived fulfillment: %v — the ACK would re-request every cycle", apps)
|
||||
}
|
||||
tails, err := s.GetCustomerLogTails("cust-a")
|
||||
if err != nil || len(tails) != 1 {
|
||||
t.Fatalf("tails = %d/%v, want 1", len(tails), err)
|
||||
}
|
||||
if len(tails[0].Lines) != 3 || tails[0].Lines[0] != "first line" || tails[0].Lines[2] != "third line" {
|
||||
t.Fatalf("tail lines lost order: %#v", tails[0].Lines)
|
||||
}
|
||||
|
||||
// keep-last-2 pruning
|
||||
s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-2"})
|
||||
s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-3"})
|
||||
tails, _ = s.GetCustomerLogTails("cust-a")
|
||||
if len(tails) != 2 {
|
||||
t.Fatalf("keep-last-2 violated: %d tails stored", len(tails))
|
||||
}
|
||||
if tails[0].Lines[0] != "tail-3" || tails[1].Lines[0] != "tail-2" {
|
||||
t.Fatalf("pruning kept the wrong tails: %v / %v", tails[0].Lines, tails[1].Lines)
|
||||
}
|
||||
|
||||
// customer-scoped single read
|
||||
if tail, _ := s.GetLogTail(tails[0].ID, "cust-b"); tail != nil {
|
||||
t.Fatalf("cross-customer tail read must return nil")
|
||||
}
|
||||
if tail, _ := s.GetLogTail(tails[0].ID, "cust-a"); tail == nil || tail.AppName != "gokapi" {
|
||||
t.Fatalf("scoped tail read failed: %+v", tail)
|
||||
}
|
||||
}
|
||||
|
||||
// Warn issues ride with no context and stay message-only end-to-end (nil-safe).
|
||||
func TestUpsertIssue_WarnNoContext(t *testing.T) {
|
||||
s := newTelemetryTestStore(t)
|
||||
saveIssue(t, s, "cust-a", "app", "warn", "WARN: slow disk", time.Now(), nil)
|
||||
is := getOneIssue(t, s, "app", longAgo, false)
|
||||
if is == nil || len(is.Context) != 0 {
|
||||
t.Fatalf("warn issue context = %#v, want none", is)
|
||||
}
|
||||
}
|
||||
|
||||
// Occurrence counting still works with the new upsert (regression guard).
|
||||
func TestUpsertIssue_OccurrenceCount(t *testing.T) {
|
||||
s := newTelemetryTestStore(t)
|
||||
msg := "ERROR: boom"
|
||||
for i := 0; i < 3; i++ {
|
||||
saveIssue(t, s, "cust-a", "app", "error", msg, time.Now().Add(time.Duration(i)*time.Minute), nil)
|
||||
}
|
||||
is := getOneIssue(t, s, "app", longAgo, false)
|
||||
if is.OccurrenceCount != 3 {
|
||||
t.Fatalf("occurrence_count = %d, want 3", is.OccurrenceCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user