From 0441c55c33b8ee3c25436cae02634729dd507d1f Mon Sep 17 00:00:00 2001 From: yusufaytas Date: Sun, 4 Jan 2026 18:55:36 +0000 Subject: [PATCH] Replace individual LogEntry URLs with collection LogEntries Individual log entries don't typically have meaningful URLs in log systems like Datadog/Kibana. Instead, URLs should point to the query results or filtered views that contain these entries. This change aligns the log API with the metrics pattern where MetricSeries has URLs, not individual MetricPoints, providing a more logical and useful interface for external linking. --- README.md | 16 +++++++++++++++- api/plugin_providers.go | 4 ++-- api/server_test.go | 19 +++++++++++-------- log/provider.go | 2 +- log/provider_test.go | 4 ++-- plugins/logmock/main.go | 19 +++++++++++++------ schema/log.go | 24 ++++++++++++++---------- 7 files changed, 58 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 6ed1784..f021fc4 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,19 @@ curl -s -X POST http://localhost:8080/incidents/p1/timeline \ # Query Alerts (requires alert provider) curl -s -X POST http://localhost:8080/alerts/query -d '{}' +# Query Logs (requires log provider) +curl -s -X POST http://localhost:8080/logs/query \ + -H "Content-Type: application/json" \ + -d '{ + "expression": { + "search": "error", + "severityIn": ["error", "critical"] + }, + "start": "2023-10-01T00:00:00Z", + "end": "2023-10-01T01:00:00Z", + "limit": 100 + }' + # Query Metrics (requires metric provider) curl -s -X POST http://localhost:8080/metrics/query \ -H "Content-Type: application/json" \ @@ -266,6 +279,7 @@ OpsOrch uses structured expressions for querying logs and metrics, replacing fre **Log Queries**: - **Structure**: `Search` (text), `Filters` (field-based), `SeverityIn`. +- **Response**: Returns `LogEntries` containing an array of log entries and optional URL to view results in the source system. - **Example**: ```json { @@ -277,7 +291,7 @@ OpsOrch uses structured expressions for querying logs and metrics, replacing fre ``` ### Provider Deep Links -Normalized resources now carry an optional `url` deep link back to the upstream system. Adapters should populate this string whenever the provider exposes a canonical UI link so OpsOrch clients can jump directly to the source incident, alert, log view, metric chart, ticket, team, service, or message. The field is passthrough only—OpsOrch does not generate, log, or modify these URLs—so adapters remain responsible for ensuring they do not leak secrets. +Normalized resources now carry optional `url` fields for deep linking back to upstream systems. For individual resources (incidents, alerts, tickets, etc.), the URL links to that specific resource. For collections like log entries and metric series, the URL links to the query results or filtered view in the source system (e.g., Datadog logs dashboard, Grafana metric chart). Adapters should populate these URLs whenever the provider exposes canonical UI links so OpsOrch clients can jump directly to the source system. The field is passthrough only—OpsOrch does not generate, log, or modify these URLs—so adapters remain responsible for ensuring they do not leak secrets. ### Adapter Architecture OpsOrch Core contains **no provider logic**. diff --git a/api/plugin_providers.go b/api/plugin_providers.go index 7780fd9..a5470a8 100644 --- a/api/plugin_providers.go +++ b/api/plugin_providers.go @@ -82,8 +82,8 @@ func newLogPluginProvider(path string, cfg map[string]any) logPluginProvider { return logPluginProvider{runner: newPluginRunner(path, cfg)} } -func (p logPluginProvider) Query(ctx context.Context, query schema.LogQuery) ([]schema.LogEntry, error) { - var res []schema.LogEntry +func (p logPluginProvider) Query(ctx context.Context, query schema.LogQuery) (schema.LogEntries, error) { + var res schema.LogEntries return res, p.runner.call(ctx, "log.query", query, &res) } diff --git a/api/server_test.go b/api/server_test.go index e4c736e..25d2537 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -86,8 +86,11 @@ func (s stubAlertProvider) Get(ctx context.Context, id string) (schema.Alert, er type stubLogProvider struct{} -func (s stubLogProvider) Query(ctx context.Context, q schema.LogQuery) ([]schema.LogEntry, error) { - return []schema.LogEntry{{Timestamp: time.Now(), Message: "log-entry", URL: logEntryURL}}, nil +func (s stubLogProvider) Query(ctx context.Context, q schema.LogQuery) (schema.LogEntries, error) { + return schema.LogEntries{ + Entries: []schema.LogEntry{{Timestamp: time.Now(), Message: "log-entry"}}, + URL: logEntryURL, + }, nil } type stubMetricProvider struct{} @@ -347,11 +350,11 @@ func TestLogQueryViaPlugin(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } - var out []schema.LogEntry + var out schema.LogEntries if err := json.NewDecoder(w.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } - if len(out) == 0 || !strings.Contains(out[0].Message, "plugin log") { + if len(out.Entries) == 0 || !strings.Contains(out.Entries[0].Message, "plugin log") { t.Fatalf("unexpected log plugin response: %+v", out) } } @@ -446,15 +449,15 @@ func TestLogQuery(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } - var out []schema.LogEntry + var out schema.LogEntries if err := json.NewDecoder(w.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } - if len(out) != 1 || out[0].Message != "log-entry" { + if len(out.Entries) != 1 || out.Entries[0].Message != "log-entry" { t.Fatalf("unexpected log response: %+v", out) } - if out[0].URL != logEntryURL { - t.Fatalf("expected log url %s, got %s", logEntryURL, out[0].URL) + if out.URL != logEntryURL { + t.Fatalf("expected log url %s, got %s", logEntryURL, out.URL) } } diff --git a/log/provider.go b/log/provider.go index 4653450..5ece99d 100644 --- a/log/provider.go +++ b/log/provider.go @@ -9,7 +9,7 @@ import ( // Provider defines the capability surface for log adapters. type Provider interface { - Query(ctx context.Context, query schema.LogQuery) ([]schema.LogEntry, error) + Query(ctx context.Context, query schema.LogQuery) (schema.LogEntries, error) } // ProviderConstructor builds a log provider from decrypted configuration. diff --git a/log/provider_test.go b/log/provider_test.go index 566c0f3..5711b56 100644 --- a/log/provider_test.go +++ b/log/provider_test.go @@ -9,8 +9,8 @@ import ( type stubLogProvider struct{} -func (stubLogProvider) Query(ctx context.Context, q schema.LogQuery) ([]schema.LogEntry, error) { - return nil, nil +func (stubLogProvider) Query(ctx context.Context, q schema.LogQuery) (schema.LogEntries, error) { + return schema.LogEntries{}, nil } func TestLogRegisterLookup(t *testing.T) { diff --git a/plugins/logmock/main.go b/plugins/logmock/main.go index 8ef83a8..2838b7f 100644 --- a/plugins/logmock/main.go +++ b/plugins/logmock/main.go @@ -18,6 +18,10 @@ type rpcResponse struct { Error string `json:"error,omitempty"` } +type LogEntries struct { + Entries []LogEntry `json:"entries"` + URL string `json:"url,omitempty"` +} type LogEntry struct { Timestamp time.Time `json:"timestamp"` Message string `json:"message"` @@ -62,12 +66,15 @@ func main() { writeErr(err) continue } - res := []LogEntry{{ - Timestamp: time.Now(), - Message: fmt.Sprintf("plugin log: %s", q.Query), - Severity: "info", - Service: q.Scope.Service, - }} + res := LogEntries{ + Entries: []LogEntry{{ + Timestamp: time.Now(), + Message: fmt.Sprintf("plugin log: %s", q.Query), + Severity: "info", + Service: q.Scope.Service, + }}, + URL: "https://logs.example.com/query?q=" + q.Query, + } writeOK(res) default: writeErr(fmt.Errorf("unknown method %s", req.Method)) diff --git a/schema/log.go b/schema/log.go index 9084722..901cffa 100644 --- a/schema/log.go +++ b/schema/log.go @@ -25,31 +25,35 @@ type LogQuery struct { Scope QueryScope `json:"scope,omitempty"` Limit int `json:"limit,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` - Providers []string `json:"providers,omitempty"` } // LogExpression defines structured log search criteria. type LogExpression struct { Search string `json:"search,omitempty"` // Full-text search term Filters []LogFilter `json:"filters,omitempty"` // Structured filters - SeverityIn []string `json:"severityIn,omitempty"` // Filter by severity levels + SeverityIn []string `json:"severityIn,omitempty"` // Filter by severity levels (normalized) } // LogFilter defines a field-level filter for logs. type LogFilter struct { - Field string `json:"field"` // Field name (e.g., "service", "message") - Operator string `json:"operator"` // "=", "!=", "contains", "regex" - Value string `json:"value"` // Filter value + Field string `json:"field"` // Field name (e.g., "service", "message", "@http.status_code") + Operator string `json:"operator"` // "=", "!=", "contains", "regex" (provider may not support all) + Value string `json:"value"` // Filter value (adapter decides casting) } // LogEntry is a normalized log record. type LogEntry struct { Timestamp time.Time `json:"timestamp"` Message string `json:"message"` - Severity string `json:"severity,omitempty"` + Severity string `json:"severity,omitempty"` // map from provider severity/status Service string `json:"service,omitempty"` - URL string `json:"url,omitempty"` - Labels map[string]string `json:"labels,omitempty"` // filterable - Fields map[string]any `json:"fields,omitempty"` // structured JSON - Metadata map[string]any `json:"metadata,omitempty"` // provider-specific + Labels map[string]string `json:"labels,omitempty"` // filterable tags (key:value) + Fields map[string]any `json:"fields,omitempty"` // structured JSON (attributes) + Metadata map[string]any `json:"metadata,omitempty"` // provider-specific (raw event, ids, etc.) +} + +// LogEntries represents a collection of log entries with optional URL to view in source system. +type LogEntries struct { + Entries []LogEntry `json:"entries"` + URL string `json:"url,omitempty"` // Link to view these results in the log system (e.g., Datadog) }