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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user