Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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
{
Expand All @@ -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**.
Expand Down
4 changes: 2 additions & 2 deletions api/plugin_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
19 changes: 11 additions & 8 deletions api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion log/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions log/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 13 additions & 6 deletions plugins/logmock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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))
Expand Down
24 changes: 14 additions & 10 deletions schema/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading