From babe64ba8dd24288eb6713c576ef184c0bdb2726 Mon Sep 17 00:00:00 2001 From: Aaron Brewbaker Date: Mon, 1 Jun 2026 22:58:26 -0400 Subject: [PATCH] feat: add Monitor V2 commands (#6) * feat: implement observe list monitor and observe get monitor (Monitor V2) Add ot_monitorv2.go defining the 'monitor' object type backed by the searchMonitorV2 and monitorV2 GraphQL queries. Add cmd_monitorv2.go registering the 'monitor' command with preview-query, preview, and alarms subcommands, using cfg.WorkspaceIdOrName (global --workspace flag) with a default of workspace 42379913. Add docs/monitor.md and comprehensive unit tests in cmd_monitorv2_test.go covering all subcommands, empty results, multiple groupings, and error paths. Closes #15 * feat: add comprehensive unit test coverage for monitor V2 commands Review existing monitor V2 tests and add missing coverage: - TestCmdListMonitorDisabledTrue/False: verify disabled field rendering - TestCmdMonitorPreviewQueryInvalidJSON: error on bad JSON input - TestCmdMonitorPreviewInvalidJSON: error on bad JSON input - TestCmdMonitorAlarmsSingleGrouping: single-grouping alarm in table - TestMonitorV2FromObjectHelper: direct helper function tests - TestMonitorV2FromObjectHelperNilFields: nil-safe field handling - TestCmdGetMonitorDefinitionJSON: definition JSON in get output - TestCmdMonitorPreviewQueryEmptyFields: empty result schema fields - TestCmdMonitorAlarmsNoSamples: alarm with null groupings field All 31 monitor-related tests pass. Closes #19 * feat: add integration tests for monitor V2 commands against live tenant Add cmd_monitorv2_integration_test.go with //go:build integration tag. Tests cover all four monitor V2 GraphQL operations against workspace 42379913: - TestIntegrationListMonitors: searchMonitorV2, tolerates empty result; if monitors exist, gets the first one by ID via monitorV2 query - TestIntegrationSearchAlarms: searchMonitorV2Alarms for all time, skips gracefully if API is unavailable - TestIntegrationPreviewQuery: evaluateMonitorV2Source using stub JSON, skipped if stub input is invalid for the tenant - TestIntegrationPreview: previewMonitorV2 using stub JSON, skipped if stub input is invalid for the tenant Add testdata/monitor_input_stub.json: a minimal MonitorV2Input definition for use in preview-query and preview integration tests. Run with: go test -tags integration ./... Closes #20 * fix: rename integrationConfig to integrationMonitorConfig to avoid redeclaration The integrationConfig function in cmd_dataset_integration_test.go (added in an earlier MR) uses no args. This file's integrationConfig(t *testing.T) caused a redeclaration compile error under the integration build tag. Rename to integrationMonitorConfig to resolve the conflict. --------- Co-authored-by: Aaron Brewbaker --- cmd_monitorv2.go | 256 +++++++++++++ cmd_monitorv2_integration_test.go | 187 ++++++++++ cmd_monitorv2_test.go | 600 ++++++++++++++++++++++++++++++ docs/monitor.md | 114 ++++++ ot_monitorv2.go | 211 +++++++++++ testdata/monitor_input_stub.json | 32 ++ 6 files changed, 1400 insertions(+) create mode 100644 cmd_monitorv2.go create mode 100644 cmd_monitorv2_integration_test.go create mode 100644 cmd_monitorv2_test.go create mode 100644 docs/monitor.md create mode 100644 ot_monitorv2.go create mode 100644 testdata/monitor_input_stub.json diff --git a/cmd_monitorv2.go b/cmd_monitorv2.go new file mode 100644 index 0000000..88cc061 --- /dev/null +++ b/cmd_monitorv2.go @@ -0,0 +1,256 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/spf13/pflag" +) + +var ( + flagsMonitor *pflag.FlagSet + flagMonitorId string + flagMonitorSince time.Duration + flagMonitorLevel string +) + +var ErrMonitorUsage = ObserveError{Msg: "usage: observe monitor [args...]"} + +func init() { + flagsMonitor = pflag.NewFlagSet("monitor", pflag.ContinueOnError) + flagsMonitor.StringVar(&flagMonitorId, "monitor-id", "", "Monitor ID to filter alarms") + flagsMonitor.DurationVar(&flagMonitorSince, "since", 24*time.Hour, "Duration to look back for alarms (e.g. 24h, 7d)") + flagsMonitor.StringVar(&flagMonitorLevel, "level", "", "Alarm level filter: critical|error|warning|informational") + RegisterCommand(&Command{ + Name: "monitor", + Help: "Query Monitor V2 resources: preview-query, preview, and alarms.", + Flags: flagsMonitor, + Func: cmdMonitor, + }) +} + +func cmdMonitor(fa FuncArgs) error { + if len(fa.args) < 2 { + return ErrMonitorUsage + } + switch fa.args[1] { + case "preview-query": + return cmdMonitorPreviewQuery(fa) + case "preview": + return cmdMonitorPreview(fa) + case "alarms": + return cmdMonitorAlarms(fa) + default: + return ObserveError{Msg: fmt.Sprintf("unknown monitor subcommand %q; expected preview-query, preview, or alarms", fa.args[1])} + } +} + +// resolveMonitorWorkspace returns the workspace ID from config or default. +// The global --workspace flag sets cfg.WorkspaceIdOrName. +func resolveMonitorWorkspace(cfg *Config) string { + if cfg.WorkspaceIdOrName != "" { + return cfg.WorkspaceIdOrName + } + return "42379913" +} + +// readMonitorV2Input reads a MonitorV2Input JSON file from disk. +func readMonitorV2Input(fa FuncArgs, filePath string) (map[string]any, error) { + data, err := fa.fs.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("monitor: could not read file %q: %w", filePath, err) + } + var input map[string]any + if err := json.Unmarshal(data, &input); err != nil { + return nil, fmt.Errorf("monitor: could not parse JSON from %q: %w", filePath, err) + } + return input, nil +} + +// ---- preview-query ---- + +var gqlEvaluateMonitorV2Source = compileGqlQuery( + `query EvaluateMonitorV2Source($input: MonitorV2Input!) { + evaluateMonitorV2Source(input: $input) { + pipeline + resultSchema { fields { name type } } + } + }`, + "data", "evaluateMonitorV2Source", +) + +func cmdMonitorPreviewQuery(fa FuncArgs) error { + // args: ["monitor", "preview-query", ""] + if len(fa.args) != 3 { + return ObserveError{Msg: "usage: observe monitor preview-query "} + } + input, err := readMonitorV2Input(fa, fa.args[2]) + if err != nil { + return err + } + obj, err := gqlEvaluateMonitorV2Source.query(fa.cfg, fa.op, fa.hc, object{"input": input}) + if err != nil { + return err + } + if obj == nil { + return fmt.Errorf("monitor preview-query: no result returned") + } + result, ok := obj.(object) + if !ok { + return fmt.Errorf("monitor preview-query: unexpected response type") + } + pipeline, _ := result["pipeline"].(string) + fmt.Fprintf(fa.op, "Pipeline:\n%s\n", pipeline) + + if schemaObj, ok := result["resultSchema"]; ok && schemaObj != nil { + if schema, ok := schemaObj.(object); ok { + if fieldsAny, ok := schema["fields"]; ok && fieldsAny != nil { + if fields, ok := fieldsAny.(array); ok { + fmt.Fprintf(fa.op, "Result schema fields:\n") + for _, f := range fields { + if fObj, ok := f.(object); ok { + name, _ := fObj["name"].(string) + typ, _ := fObj["type"].(string) + fmt.Fprintf(fa.op, " %s: %s\n", name, typ) + } + } + } + } + } + } + return nil +} + +// ---- preview ---- + +var gqlPreviewMonitorV2 = compileGqlQuery( + `query PreviewMonitorV2($workspaceId: ObjectId!, $input: MonitorV2Input!) { + previewMonitorV2(workspaceId: $workspaceId, input: $input) { + wouldFire + samples { groupings { name value } level timestamp } + } + }`, + "data", "previewMonitorV2", +) + +func cmdMonitorPreview(fa FuncArgs) error { + // args: ["monitor", "preview", ""] + if len(fa.args) != 3 { + return ObserveError{Msg: "usage: observe monitor preview "} + } + input, err := readMonitorV2Input(fa, fa.args[2]) + if err != nil { + return err + } + workspaceId := resolveMonitorWorkspace(fa.cfg) + obj, err := gqlPreviewMonitorV2.query(fa.cfg, fa.op, fa.hc, object{ + "workspaceId": workspaceId, + "input": input, + }) + if err != nil { + return err + } + if obj == nil { + return fmt.Errorf("monitor preview: no result returned") + } + result, ok := obj.(object) + if !ok { + return fmt.Errorf("monitor preview: unexpected response type") + } + wouldFire, _ := result["wouldFire"].(bool) + fmt.Fprintf(fa.op, "Would fire: %v\n", wouldFire) + + if samplesAny, ok := result["samples"]; ok && samplesAny != nil { + if samples, ok := samplesAny.(array); ok && len(samples) > 0 { + fmt.Fprintf(fa.op, "Samples:\n") + for _, s := range samples { + sObj, ok := s.(object) + if !ok { + continue + } + level, _ := sObj["level"].(string) + timestamp, _ := sObj["timestamp"].(string) + fmt.Fprintf(fa.op, " level=%s timestamp=%s", level, timestamp) + if groupingsAny, ok := sObj["groupings"]; ok && groupingsAny != nil { + if groupings, ok := groupingsAny.(array); ok { + for _, g := range groupings { + gObj, ok := g.(object) + if !ok { + continue + } + gName, _ := gObj["name"].(string) + gValue, _ := gObj["value"].(string) + fmt.Fprintf(fa.op, " %s=%s", gName, gValue) + } + } + } + fmt.Fprintf(fa.op, "\n") + } + } + } + return nil +} + +// ---- alarms ---- + +var gqlSearchMonitorV2Alarms = compileGqlQuery( + `query SearchMonitorV2Alarms($workspaceId: ObjectId!, $monitorId: ObjectId, $startTime: Time, $endTime: Time, $levels: [MonitorV2AlarmLevel!]) { + searchMonitorV2Alarms(workspaceId: $workspaceId, monitorId: $monitorId, startTime: $startTime, endTime: $endTime, levels: $levels) { + alarms { id monitorId level status startTime endTime groupings { name value } } + } + }`, + "data", "searchMonitorV2Alarms", "alarms", +) + +func cmdMonitorAlarms(fa FuncArgs) error { + workspaceId := resolveMonitorWorkspace(fa.cfg) + + args := object{"workspaceId": workspaceId} + + if flagMonitorId != "" { + args["monitorId"] = flagMonitorId + } + + if flagMonitorSince > 0 { + startTime := time.Now().Add(-flagMonitorSince).UTC().Format(time.RFC3339) + args["startTime"] = startTime + } + + if flagMonitorLevel != "" { + args["levels"] = []string{flagMonitorLevel} + } + + obj, err := gqlSearchMonitorV2Alarms.query(fa.cfg, fa.op, fa.hc, args) + if err != nil { + return err + } + if obj == nil { + return nil + } + alarms, ok := obj.(array) + if !ok { + return fmt.Errorf("monitor alarms: unexpected response type") + } + + out := &ColumnFormatter{ + Output: fa.op, + OmitLineDrawing: true, + } + out.SetColumnNames([]string{"id", "monitorId", "level", "status", "startTime", "endTime"}) + for _, a := range alarms { + aObj, ok := a.(object) + if !ok { + continue + } + id, _ := aObj["id"].(string) + monitorId, _ := aObj["monitorId"].(string) + level, _ := aObj["level"].(string) + status, _ := aObj["status"].(string) + startTime, _ := aObj["startTime"].(string) + endTime, _ := aObj["endTime"].(string) + out.AddRow([]string{id, monitorId, level, status, startTime, endTime}) + } + out.Close() + return nil +} diff --git a/cmd_monitorv2_integration_test.go b/cmd_monitorv2_integration_test.go new file mode 100644 index 0000000..355426b --- /dev/null +++ b/cmd_monitorv2_integration_test.go @@ -0,0 +1,187 @@ +//go:build integration + +package main + +import ( + "encoding/json" + "net/http" + "os" + "strings" + "testing" +) + +// Integration tests for Monitor V2 commands. +// Run with: go test -tags integration ./... +// +// These tests connect to the live Observe tenant at +// 109601619518.observeinc.com using workspace 42379913. +// They require a valid OBSERVE_CONFIG or ~/.config/observe.yaml profile. +// +// Tests are designed to tolerate an empty monitor/alarm list so they +// do not require pre-existing data to pass. + +const integrationWorkspaceId = "42379913" + +// integrationMonitorConfig returns a Config for the live tenant. +// It reads from the default profile in observe.yaml. +// (Named integrationMonitorConfig to avoid collision with integrationConfig +// defined in cmd_dataset_integration_test.go.) +func integrationMonitorConfig(t *testing.T) *Config { + t.Helper() + cfg := &Config{} + configPath := GetConfigFilePath() + if _, err := os.Stat(configPath); err != nil { + t.Skipf("no config file found at %s, skipping integration test", configPath) + } + if err := ReadConfig(cfg, configPath, "default", false); err != nil { + t.Skipf("could not read config: %s", err) + } + if cfg.AuthtokenStr == "" { + t.Skip("no authtoken in config, skipping integration test") + } + return cfg +} + +// TestIntegrationListMonitors verifies that searchMonitorV2 returns without error. +// The result may be empty; that is acceptable. +func TestIntegrationListMonitors(t *testing.T) { + cfg := integrationMonitorConfig(t) + op := NewCaptureOutput() + hc := &http.Client{} + + args := object{"workspaceId": integrationWorkspaceId} + obj, err := gqlListMonitorV2.query(cfg, op, hc, args) + if err != nil { + t.Fatalf("searchMonitorV2 query failed: %s", err) + } + items, ok := obj.(array) + if !ok { + t.Fatalf("expected array result from searchMonitorV2, got %T", obj) + } + t.Logf("found %d monitors in workspace %s", len(items), integrationWorkspaceId) + + // If monitors exist, get the first one by ID. + if len(items) > 0 { + firstItem, ok := items[0].(object) + if !ok { + t.Fatal("first monitor item is not an object") + } + id, ok := firstItem["id"].(string) + if !ok || id == "" { + t.Fatal("first monitor item has no id") + } + t.Logf("getting monitor id=%s", id) + gotObj, err := gqlGetMonitorV2.query(cfg, op, hc, object{"id": id}) + if err != nil { + t.Fatalf("monitorV2 query failed for id=%s: %s", id, err) + } + if gotObj == nil { + t.Errorf("monitorV2 returned nil for id=%s", id) + } + gotMap, ok := gotObj.(object) + if !ok { + t.Fatalf("monitorV2 response is not an object: %T", gotObj) + } + gotId, _ := gotMap["id"].(string) + if gotId != id { + t.Errorf("expected id=%s, got %s", id, gotId) + } + t.Logf("monitor: id=%s name=%v", gotId, gotMap["name"]) + } +} + +// TestIntegrationSearchAlarms verifies that searchMonitorV2Alarms returns +// without error for the last 24 hours in the default workspace. +func TestIntegrationSearchAlarms(t *testing.T) { + cfg := integrationMonitorConfig(t) + op := NewCaptureOutput() + hc := &http.Client{} + + // Use a far-past start time to retrieve all alarms ever recorded. + args := object{ + "workspaceId": integrationWorkspaceId, + "startTime": "2000-01-01T00:00:00Z", + } + obj, err := gqlSearchMonitorV2Alarms.query(cfg, op, hc, args) + if err != nil { + // Some tenants may not have the alarms API enabled; skip rather than fail. + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not supported") { + t.Skipf("searchMonitorV2Alarms not available: %s", err) + } + t.Fatalf("searchMonitorV2Alarms query failed: %s", err) + } + alarms, ok := obj.(array) + if !ok { + t.Fatalf("expected array result from searchMonitorV2Alarms, got %T", obj) + } + t.Logf("found %d alarms in workspace %s", len(alarms), integrationWorkspaceId) +} + +// TestIntegrationPreviewQuery validates evaluateMonitorV2Source against a +// stub MonitorV2Input from testdata/monitor_input_stub.json. +// This test is skipped if the API returns an error (e.g. the stub input +// references a dataset that doesn't exist in the tenant). +func TestIntegrationPreviewQuery(t *testing.T) { + cfg := integrationMonitorConfig(t) + op := NewCaptureOutput() + hc := &http.Client{} + + data, err := os.ReadFile("testdata/monitor_input_stub.json") + if err != nil { + t.Skipf("could not read testdata/monitor_input_stub.json: %s", err) + } + + var input map[string]any + if err := json.Unmarshal(data, &input); err != nil { + t.Skipf("could not parse testdata/monitor_input_stub.json: %s", err) + } + + obj, err := gqlEvaluateMonitorV2Source.query(cfg, op, hc, object{"input": input}) + if err != nil { + t.Skipf("evaluateMonitorV2Source not available or stub input invalid: %s", err) + } + if obj == nil { + t.Skip("evaluateMonitorV2Source returned nil; skipping") + } + result, ok := obj.(object) + if !ok { + t.Fatalf("expected object from evaluateMonitorV2Source, got %T", obj) + } + pipeline, _ := result["pipeline"].(string) + t.Logf("pipeline: %s", pipeline) +} + +// TestIntegrationPreview validates previewMonitorV2 against the stub input. +// Skipped if the API returns an error or stub input is invalid for the tenant. +func TestIntegrationPreview(t *testing.T) { + cfg := integrationMonitorConfig(t) + op := NewCaptureOutput() + hc := &http.Client{} + + data, err := os.ReadFile("testdata/monitor_input_stub.json") + if err != nil { + t.Skipf("could not read testdata/monitor_input_stub.json: %s", err) + } + + var input map[string]any + if err := json.Unmarshal(data, &input); err != nil { + t.Skipf("could not parse testdata/monitor_input_stub.json: %s", err) + } + + obj, err := gqlPreviewMonitorV2.query(cfg, op, hc, object{ + "workspaceId": integrationWorkspaceId, + "input": input, + }) + if err != nil { + t.Skipf("previewMonitorV2 not available or stub input invalid: %s", err) + } + if obj == nil { + t.Skip("previewMonitorV2 returned nil; skipping") + } + result, ok := obj.(object) + if !ok { + t.Fatalf("expected object from previewMonitorV2, got %T", obj) + } + wouldFire, _ := result["wouldFire"].(bool) + t.Logf("wouldFire: %v", wouldFire) +} diff --git a/cmd_monitorv2_test.go b/cmd_monitorv2_test.go new file mode 100644 index 0000000..97b29ad --- /dev/null +++ b/cmd_monitorv2_test.go @@ -0,0 +1,600 @@ +package main + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// --- Issue #15: list monitor / get monitor --- + +func TestCmdListMonitor(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2":{"monitors":[ + {"id":"mon-001","name":"CPU Alert","description":"CPU usage monitor","disabled":false,"updatedDate":"2024-01-01T00:00:00Z"}, + {"id":"mon-002","name":"Memory Alert","description":"Memory usage monitor","disabled":true,"updatedDate":"2024-01-02T00:00:00Z"} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "monitor"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "mon-001") { + t.Error("expected mon-001 in output:", out) + } + if !strings.Contains(out, "CPU Alert") { + t.Error("expected CPU Alert in output:", out) + } + if !strings.Contains(out, "mon-002") { + t.Error("expected mon-002 in output:", out) + } + if !strings.Contains(out, "Memory Alert") { + t.Error("expected Memory Alert in output:", out) + } +} + +func TestCmdListMonitorEmpty(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2":{"monitors":[]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "monitor"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + // With empty monitors, the table should only have the header row (no data rows). + out := fix.op.OutputBuf.String() + // header should be present + if !strings.Contains(out, "id") { + t.Error("expected header row in output:", out) + } + // no data rows expected + if strings.Contains(out, "mon-") { + t.Error("unexpected monitor data in empty list output:", out) + } +} + +func TestCmdListMonitorDefaultWorkspace(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2":{"monitors":[]}}}`}, + ) + // No workspace set in cfg, should use default 42379913 + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "monitor"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + // Verify the request was made (rix == 1 means the request was consumed) + fix.Assert() +} + +func TestCmdGetMonitor(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"monitorV2":{ + "id":"mon-001", + "name":"CPU Alert", + "description":"CPU usage monitor", + "disabled":false, + "updatedDate":"2024-01-01T00:00:00Z", + "definition":{"compareFunction":"GREATER","countAggFunction":"COUNT","threshold":90} + }}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"get", "monitor", "mon-001"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "mon-001") { + t.Error("expected id in output:", out) + } + if !strings.Contains(out, "CPU Alert") { + t.Error("expected name in output:", out) + } + if !strings.Contains(out, "monitor") { + t.Error("expected type in output:", out) + } +} + +func TestCmdListMonitorTableColumns(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2":{"monitors":[ + {"id":"mon-abc","name":"Test Monitor","description":"","disabled":false,"updatedDate":"2024-06-01T12:00:00Z"} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "monitor"}, fix.hc) + out := fix.op.OutputBuf.String() + // Table should contain all 4 presentation columns + if !strings.Contains(out, "id") { + t.Error("missing 'id' column header:", out) + } + if !strings.Contains(out, "name") { + t.Error("missing 'name' column header:", out) + } + if !strings.Contains(out, "disabled") { + t.Error("missing 'disabled' column header:", out) + } + if !strings.Contains(out, "updatedDate") { + t.Error("missing 'updatedDate' column header:", out) + } + if !strings.Contains(out, "mon-abc") { + t.Error("missing monitor id in data:", out) + } +} + +func TestCmdMonitorUnknownSubcommand(t *testing.T) { + fix := startFixture(t) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from Exit on error") + } + }() + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "unknown-subcommand"}, fix.hc) +} + +func TestCmdMonitorNoSubcommand(t *testing.T) { + fix := startFixture(t) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from Exit on error") + } + }() + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor"}, fix.hc) +} + +// --- Issue #16: preview-query --- + +func TestCmdMonitorPreviewQuery(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"evaluateMonitorV2Source":{ + "pipeline":"filter value > 90", + "resultSchema":{"fields":[ + {"name":"timestamp","type":"time"}, + {"name":"value","type":"float64"} + ]} + }}}`}, + ) + input := `{"name":"CPU Alert","ruleKind":"COUNT","definition":{"compareFunction":"GREATER","threshold":90}}` + fix.fs.WriteFile("monitor_input.json", []byte(input), 0) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview-query", "monitor_input.json"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "filter value > 90") { + t.Error("expected pipeline in output:", out) + } + if !strings.Contains(out, "timestamp") { + t.Error("expected field name in output:", out) + } + if !strings.Contains(out, "time") { + t.Error("expected field type in output:", out) + } +} + +func TestCmdMonitorPreviewQueryMissingFile(t *testing.T) { + fix := startFixture(t) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from Exit on error") + } + }() + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview-query", "nonexistent.json"}, fix.hc) +} + +func TestCmdMonitorPreviewQueryMissingArg(t *testing.T) { + fix := startFixture(t) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from Exit on error") + } + }() + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview-query"}, fix.hc) +} + +func TestCmdMonitorPreviewQueryNoSchema(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"evaluateMonitorV2Source":{ + "pipeline":"filter count > 5", + "resultSchema":null + }}}`}, + ) + input := `{"name":"Count Alert","ruleKind":"COUNT","definition":{"compareFunction":"GREATER","threshold":5}}` + fix.fs.WriteFile("monitor_input2.json", []byte(input), 0) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview-query", "monitor_input2.json"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "filter count > 5") { + t.Error("expected pipeline in output:", out) + } +} + +// --- Issue #17: preview --- + +func TestCmdMonitorPreviewWouldFire(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"previewMonitorV2":{ + "wouldFire":true, + "samples":[ + {"groupings":[{"name":"host","value":"web-01"}],"level":"critical","timestamp":"2024-01-01T00:00:00Z"} + ] + }}}`}, + ) + input := `{"name":"CPU Alert","ruleKind":"COUNT","definition":{"compareFunction":"GREATER","threshold":90}}` + fix.fs.WriteFile("preview_input.json", []byte(input), 0) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview", "preview_input.json"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "Would fire: true") { + t.Error("expected 'Would fire: true' in output:", out) + } + if !strings.Contains(out, "critical") { + t.Error("expected level in output:", out) + } + if !strings.Contains(out, "web-01") { + t.Error("expected grouping value in output:", out) + } +} + +func TestCmdMonitorPreviewWouldNotFire(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"previewMonitorV2":{ + "wouldFire":false, + "samples":[] + }}}`}, + ) + input := `{"name":"CPU Alert","ruleKind":"COUNT","definition":{"compareFunction":"GREATER","threshold":90}}` + fix.fs.WriteFile("preview_input2.json", []byte(input), 0) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview", "preview_input2.json"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "Would fire: false") { + t.Error("expected 'Would fire: false' in output:", out) + } +} + +func TestCmdMonitorPreviewMultipleGroupings(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"previewMonitorV2":{ + "wouldFire":true, + "samples":[ + {"groupings":[{"name":"host","value":"db-01"},{"name":"env","value":"prod"}],"level":"warning","timestamp":"2024-01-01T06:00:00Z"} + ] + }}}`}, + ) + input := `{"name":"DB Alert","ruleKind":"COUNT","definition":{"compareFunction":"GREATER","threshold":50}}` + fix.fs.WriteFile("preview_input3.json", []byte(input), 0) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview", "preview_input3.json"}, fix.hc) + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "db-01") { + t.Error("expected first grouping value in output:", out) + } + if !strings.Contains(out, "prod") { + t.Error("expected second grouping value in output:", out) + } + if !strings.Contains(out, "warning") { + t.Error("expected level in output:", out) + } +} + +func TestCmdMonitorPreviewMissingArg(t *testing.T) { + fix := startFixture(t) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from Exit on error") + } + }() + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview"}, fix.hc) +} + +// --- Issue #18: alarms --- + +func TestCmdMonitorAlarms(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2Alarms":{"alarms":[ + {"id":"alarm-001","monitorId":"mon-001","level":"critical","status":"active","startTime":"2024-01-01T00:00:00Z","endTime":"","groupings":[{"name":"host","value":"web-01"}]}, + {"id":"alarm-002","monitorId":"mon-002","level":"warning","status":"resolved","startTime":"2024-01-01T01:00:00Z","endTime":"2024-01-01T02:00:00Z","groupings":[]} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "alarms"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "alarm-001") { + t.Error("expected alarm-001 in output:", out) + } + if !strings.Contains(out, "critical") { + t.Error("expected level in output:", out) + } + if !strings.Contains(out, "alarm-002") { + t.Error("expected alarm-002 in output:", out) + } + if !strings.Contains(out, "warning") { + t.Error("expected warning level in output:", out) + } +} + +func TestCmdMonitorAlarmsEmpty(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2Alarms":{"alarms":[]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "alarms"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + // With no alarms, output should have header only + out := fix.op.OutputBuf.String() + if strings.Contains(out, "alarm-") { + t.Error("unexpected alarm data in empty output:", out) + } +} + +func TestCmdMonitorAlarmsTableColumns(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2Alarms":{"alarms":[ + {"id":"alarm-xyz","monitorId":"mon-abc","level":"informational","status":"active","startTime":"2024-06-01T00:00:00Z","endTime":"","groupings":[]} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "alarms"}, fix.hc) + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "id") { + t.Error("missing 'id' column:", out) + } + if !strings.Contains(out, "monitorId") { + t.Error("missing 'monitorId' column:", out) + } + if !strings.Contains(out, "level") { + t.Error("missing 'level' column:", out) + } + if !strings.Contains(out, "status") { + t.Error("missing 'status' column:", out) + } + if !strings.Contains(out, "startTime") { + t.Error("missing 'startTime' column:", out) + } + if !strings.Contains(out, "endTime") { + t.Error("missing 'endTime' column:", out) + } +} + +func TestCmdMonitorAlarmsMultipleGroupings(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2Alarms":{"alarms":[ + {"id":"alarm-mg","monitorId":"mon-001","level":"error","status":"active","startTime":"2024-06-01T00:00:00Z","endTime":"","groupings":[{"name":"host","value":"db-01"},{"name":"region","value":"us-east-1"}]} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "alarms"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "alarm-mg") { + t.Error("expected alarm id in output:", out) + } + if !strings.Contains(out, "error") { + t.Error("expected level in output:", out) + } +} + +func TestMonitorV2ObjectTypeRegistered(t *testing.T) { + ot := GetObjectType("monitor") + if ot == nil { + t.Fatal("expected 'monitor' object type to be registered") + } + if ot.TypeName() != "monitor" { + t.Errorf("expected TypeName 'monitor', got %q", ot.TypeName()) + } + if !ot.CanList() { + t.Error("expected CanList() == true") + } + if !ot.CanGet() { + t.Error("expected CanGet() == true") + } + if ot.CanCreate() { + t.Error("expected CanCreate() == false") + } + if ot.CanDelete() { + t.Error("expected CanDelete() == false") + } +} + +func TestMonitorV2PresentationLabels(t *testing.T) { + ot := GetObjectType("monitor") + if ot == nil { + t.Fatal("expected 'monitor' object type to be registered") + } + labels := ot.GetPresentationLabels() + expected := []string{"id", "name", "disabled", "updatedDate"} + if diff := cmp.Diff(labels, expected); diff != "" { + t.Errorf("unexpected presentation labels:\n%s", diff) + } +} + +// --- Issue #19: additional unit test coverage --- + +func TestCmdListMonitorDisabledTrue(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2":{"monitors":[ + {"id":"mon-disabled","name":"Disabled Monitor","description":"","disabled":true,"updatedDate":"2024-01-01T00:00:00Z"} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "monitor"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "mon-disabled") { + t.Error("expected disabled monitor id in output:", out) + } + if !strings.Contains(out, "true") { + t.Error("expected 'true' disabled value in output:", out) + } +} + +func TestCmdListMonitorDisabledFalse(t *testing.T) { + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2":{"monitors":[ + {"id":"mon-enabled","name":"Active Monitor","description":"","disabled":false,"updatedDate":"2024-02-01T00:00:00Z"} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "monitor"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "false") { + t.Error("expected 'false' disabled value in output:", out) + } +} + +func TestCmdMonitorPreviewQueryInvalidJSON(t *testing.T) { + fix := startFixture(t) + fix.fs.WriteFile("bad.json", []byte("{not valid json"), 0) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from Exit on error with invalid JSON") + } + }() + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview-query", "bad.json"}, fix.hc) +} + +func TestCmdMonitorPreviewInvalidJSON(t *testing.T) { + fix := startFixture(t) + fix.fs.WriteFile("bad2.json", []byte("{not valid json"), 0) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from Exit on error with invalid JSON") + } + }() + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview", "bad2.json"}, fix.hc) +} + +func TestCmdMonitorAlarmsSingleGrouping(t *testing.T) { + // Verify that alarms with a single grouping are properly included in the table output + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2Alarms":{"alarms":[ + {"id":"alarm-single","monitorId":"mon-001","level":"critical","status":"active","startTime":"2024-06-01T00:00:00Z","endTime":"","groupings":[{"name":"host","value":"prod-web-01"}]} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "alarms"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "alarm-single") { + t.Error("expected alarm id in output:", out) + } + if !strings.Contains(out, "critical") { + t.Error("expected level in output:", out) + } +} + +func TestMonitorV2FromObjectHelper(t *testing.T) { + m := object{ + "id": "mon-test", + "name": "Test Monitor", + "description": "A test", + "disabled": true, + "updatedDate": "2024-01-01T00:00:00Z", + } + o := monitorV2FromObject(m) + if o.Id != "mon-test" { + t.Errorf("expected Id 'mon-test', got %q", o.Id) + } + if o.Name != "Test Monitor" { + t.Errorf("expected Name 'Test Monitor', got %q", o.Name) + } + if o.Description != "A test" { + t.Errorf("expected Description 'A test', got %q", o.Description) + } + if o.Disabled != "true" { + t.Errorf("expected Disabled 'true', got %q", o.Disabled) + } + if o.UpdatedDate != "2024-01-01T00:00:00Z" { + t.Errorf("expected UpdatedDate '2024-01-01T00:00:00Z', got %q", o.UpdatedDate) + } +} + +func TestMonitorV2FromObjectHelperNilFields(t *testing.T) { + // Ensure monitorV2FromObject handles nil field values without panicking + m := object{ + "id": nil, + "name": nil, + } + o := monitorV2FromObject(m) + if o.Id != "" { + t.Errorf("expected empty Id, got %q", o.Id) + } + if o.Name != "" { + t.Errorf("expected empty Name, got %q", o.Name) + } +} + +func TestCmdGetMonitorDefinitionJSON(t *testing.T) { + // Verify that the definition field is marshalled to JSON in get output + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"monitorV2":{ + "id":"mon-def", + "name":"Def Monitor", + "description":"", + "disabled":false, + "updatedDate":"2024-01-01T00:00:00Z", + "definition":{"compareFunction":"GREATER","countAggFunction":"COUNT","threshold":100} + }}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"get", "monitor", "mon-def"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "mon-def") { + t.Error("expected id in output:", out) + } + // The definition should appear as JSON in the state section + if !strings.Contains(out, "GREATER") { + t.Error("expected definition content in output:", out) + } +} + +func TestCmdMonitorPreviewQueryEmptyFields(t *testing.T) { + // Verify empty result schema fields array is handled gracefully + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"evaluateMonitorV2Source":{ + "pipeline":"filter true", + "resultSchema":{"fields":[]} + }}}`}, + ) + input := `{"name":"Empty Schema","ruleKind":"COUNT","definition":{"compareFunction":"GREATER","threshold":0}}` + fix.fs.WriteFile("empty_schema.json", []byte(input), 0) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "preview-query", "empty_schema.json"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "filter true") { + t.Error("expected pipeline in output:", out) + } +} + +func TestCmdMonitorAlarmsNoSamples(t *testing.T) { + // Alarm result set with nil groupings field + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"searchMonitorV2Alarms":{"alarms":[ + {"id":"alarm-nogroupings","monitorId":"mon-001","level":"warning","status":"resolved","startTime":"2024-06-01T00:00:00Z","endTime":"2024-06-01T01:00:00Z","groupings":null} + ]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"monitor", "alarms"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "alarm-nogroupings") { + t.Error("expected alarm id in output:", out) + } +} diff --git a/docs/monitor.md b/docs/monitor.md new file mode 100644 index 0000000..30f381c --- /dev/null +++ b/docs/monitor.md @@ -0,0 +1,114 @@ +# monitor + + observe monitor preview-query + observe monitor preview [--workspace ] + observe monitor alarms [--workspace ] [--monitor-id ] [--since ] [--level ] + +The monitor command provides access to Monitor V2 resources in your Observe +workspace. Use `observe list monitor` and `observe get monitor` to browse +existing monitors, and the subcommands below for advanced operations. + +Monitor V2 is Observe's rule-based alerting engine. Each monitor watches an +OPAL pipeline and fires alarms when conditions are met (e.g. a count exceeds a +threshold). Alarms have levels: critical, error, warning, or informational. + +## Listing and Getting Monitors + + observe list monitor [] + observe get monitor + +`observe list monitor` queries `searchMonitorV2` and displays a table with +columns: id, name, disabled, updatedDate. Pass an optional substring to filter +by name. Use `--workspace ` to target a non-default workspace. + +`observe get monitor ` retrieves a single monitor by ID using `monitorV2` +and prints it in YAML format, including the definition block. + +## Subcommands + +### preview-query + + observe monitor preview-query + +Reads a `MonitorV2Input` JSON file and calls `evaluateMonitorV2Source` to +compile the monitor definition into its OPAL pipeline representation. Prints +the generated pipeline and the result schema (field names and types). Useful +for validating a monitor definition before creating it. + +The JSON file must be a valid `MonitorV2Input` object with at minimum: +- `name` (string) +- `ruleKind` (MonitorV2RuleKind enum) +- `definition` (MonitorV2Definition union type) + +### preview + + observe monitor preview [--workspace ] + +Reads a `MonitorV2Input` JSON file and calls `previewMonitorV2` against the +workspace to evaluate whether the monitor would currently fire. Prints: +- "Would fire: true/false" +- Any sample alarm groupings with their level and timestamp + +Use `--workspace ` or the global `--workspace` flag to specify the target +workspace. Defaults to workspace `42379913`. + +### alarms + + observe monitor alarms [--workspace ] [--monitor-id ] [--since ] [--level ] + +Searches for Monitor V2 alarms using `searchMonitorV2Alarms` and displays +them in a table with columns: id, monitorId, level, status, startTime, endTime. + +Options: +- `--monitor-id `: filter alarms to a specific monitor +- `--since `: look back this far (default: 24h); accepts Go duration + strings such as `1h`, `24h`, `7d` (interpreted as hours/minutes/seconds) +- `--level `: filter by alarm level (critical, error, warning, informational) + +Use the global `--workspace` flag to target a non-default workspace. + +## Examples + +List all monitors in the default workspace: + + observe list monitor + +List monitors whose name contains "CPU": + + observe list monitor CPU + +Get a specific monitor: + + observe get monitor 41234567 + +Evaluate a monitor definition: + + observe monitor preview-query my-monitor.json + +Preview whether a monitor would fire now: + + observe monitor preview my-monitor.json + +Preview in a specific workspace: + + observe --workspace 42379913 monitor preview my-monitor.json + +Show alarms from the last 7 days: + + observe monitor alarms --since 168h + +Show only critical alarms for a specific monitor: + + observe monitor alarms --monitor-id 41234567 --level critical + +## MonitorV2Input JSON Format + + { + "name": "My CPU Monitor", + "ruleKind": "COUNT", + "definition": { + "compareFunction": "GREATER", + "countAggFunction": "COUNT", + "threshold": 90 + } + } diff --git a/ot_monitorv2.go b/ot_monitorv2.go new file mode 100644 index 0000000..095671c --- /dev/null +++ b/ot_monitorv2.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +func init() { + RegisterObjectType(ObjectTypeMonitorV2, &objectMonitorV2{}) +} + +type objectMonitorV2 struct { + Id string + Name string + Description string + Disabled string + UpdatedDate string + Definition string +} + +var _ ObjectInstance = &objectMonitorV2{} + +func (o *objectMonitorV2) GetInfo() *ObjectInfo { + return &ObjectInfo{ + Id: o.Id, + Name: o.Name, + Presentation: []string{o.Id, o.Name, o.Disabled, o.UpdatedDate}, + Object: o, + } +} + +func (o *objectMonitorV2) GetValues() []PropertyInstance { + props := ObjectTypeMonitorV2.GetProperties() + r := make([]PropertyInstance, len(props)) + for i, p := range props { + r[i] = &propertyInstance{p, o} + } + return r +} + +func (o *objectMonitorV2) PrintToYaml(op Output, otyp ObjectType, obj ObjectInstance) error { + return printToYamlFromObjectInstance(op, otyp, obj) +} + +type objectTypeMonitorV2 struct{} + +var ObjectTypeMonitorV2 ObjectType = &objectTypeMonitorV2{} + +var propertyDescMonitorV2 = []PropertyDesc{ + {"id", PropertyTypeString, false, true, + func(o any) any { return o.(*objectMonitorV2).Id }, + func(o any, v any) { + if v != nil { + o.(*objectMonitorV2).Id = v.(string) + } + }}, + {"name", PropertyTypeString, false, false, + func(o any) any { return o.(*objectMonitorV2).Name }, + func(o any, v any) { + if v != nil { + o.(*objectMonitorV2).Name = v.(string) + } + }}, + {"description", PropertyTypeString, false, false, + func(o any) any { return o.(*objectMonitorV2).Description }, + func(o any, v any) { + if v != nil { + o.(*objectMonitorV2).Description = v.(string) + } + }}, + {"disabled", PropertyTypeString, false, false, + func(o any) any { return o.(*objectMonitorV2).Disabled }, + func(o any, v any) { + if v != nil { + o.(*objectMonitorV2).Disabled = fmt.Sprintf("%v", v) + } + }}, + {"updatedDate", PropertyTypeString, true, false, + func(o any) any { return o.(*objectMonitorV2).UpdatedDate }, + func(o any, v any) { + if v != nil { + o.(*objectMonitorV2).UpdatedDate = v.(string) + } + }}, + {"definition", PropertyTypeString, true, false, + func(o any) any { return o.(*objectMonitorV2).Definition }, + func(o any, v any) { + if v != nil { + o.(*objectMonitorV2).Definition = fmt.Sprintf("%v", v) + } + }}, +} + +func (*objectTypeMonitorV2) TypeName() string { return "monitor" } +func (*objectTypeMonitorV2) Help() string { + return "A Monitor V2 watches a data pipeline and fires alarms when conditions are met." +} +func (*objectTypeMonitorV2) CanList() bool { return true } +func (*objectTypeMonitorV2) CanGet() bool { return true } +func (*objectTypeMonitorV2) CanCreate() bool { return false } +func (*objectTypeMonitorV2) CanUpdate() bool { return false } +func (*objectTypeMonitorV2) CanDelete() bool { return false } +func (*objectTypeMonitorV2) GetPresentationLabels() []string { return []string{"id", "name", "disabled", "updatedDate"} } +func (*objectTypeMonitorV2) GetProperties() []PropertyDesc { return propertyDescMonitorV2 } + +var gqlListMonitorV2 = compileGqlQuery( + `query SearchMonitorV2($workspaceId: ObjectId!, $nameSubstring: String) { + searchMonitorV2(workspaceId: $workspaceId, nameSubstring: $nameSubstring) { + monitors { id name description disabled updatedDate } + } + }`, + "data", "searchMonitorV2", "monitors", +) + +func (ot *objectTypeMonitorV2) List(cfg *Config, op Output, hc httpClient) ([]*ObjectInfo, error) { + workspaceId := cfg.WorkspaceIdOrName + if workspaceId == "" { + workspaceId = "42379913" + } + args := object{"workspaceId": workspaceId} + obj, err := gqlListMonitorV2.query(cfg, op, hc, args) + if err != nil || obj == nil { + return nil, err + } + items, ok := obj.(array) + if !ok { + return nil, fmt.Errorf("monitor list: unexpected response type") + } + var ret []*ObjectInfo + for _, item := range items { + m, ok := item.(object) + if !ok { + continue + } + o := monitorV2FromObject(m) + ret = append(ret, o.GetInfo()) + } + return ret, nil +} + +var gqlGetMonitorV2 = compileGqlQuery( + `query GetMonitorV2($id: ObjectId!) { + monitorV2(id: $id) { + id name description disabled updatedDate + definition { + ... on MonitorV2CountDefinition { + compareFunction + countAggFunction + threshold + } + } + } + }`, + "data", "monitorV2", +) + +func (ot *objectTypeMonitorV2) Get(cfg *Config, op Output, hc httpClient, id string) (ObjectInstance, error) { + obj, err := gqlGetMonitorV2.query(cfg, op, hc, object{"id": id}) + if err != nil { + return nil, err + } + if obj == nil { + return nil, nil + } + raw, ok := obj.(object) + if !ok { + return nil, fmt.Errorf("monitor get: unexpected response type") + } + o := monitorV2FromObject(raw) + if v, ok := raw["definition"]; ok && v != nil { + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("monitor get: failed to marshal definition: %w", err) + } + o.Definition = string(b) + } + return o, nil +} + +func (ot *objectTypeMonitorV2) Create(cfg *Config, op Output, hc httpClient, input object) (ObjectInstance, error) { + return nil, nil +} + +func (ot *objectTypeMonitorV2) Update(cfg *Config, op Output, hc httpClient, id string, input object) (ObjectInstance, error) { + return nil, nil +} + +func (ot *objectTypeMonitorV2) Delete(cfg *Config, op Output, hc httpClient, id string) error { + return nil +} + +// monitorV2FromObject populates an objectMonitorV2 from a GraphQL response map. +func monitorV2FromObject(m object) *objectMonitorV2 { + o := &objectMonitorV2{} + if v, ok := m["id"]; ok && v != nil { + o.Id = v.(string) + } + if v, ok := m["name"]; ok && v != nil { + o.Name = v.(string) + } + if v, ok := m["description"]; ok && v != nil { + o.Description = v.(string) + } + if v, ok := m["disabled"]; ok && v != nil { + o.Disabled = fmt.Sprintf("%v", v) + } + if v, ok := m["updatedDate"]; ok && v != nil { + o.UpdatedDate = v.(string) + } + return o +} diff --git a/testdata/monitor_input_stub.json b/testdata/monitor_input_stub.json new file mode 100644 index 0000000..be2eaa0 --- /dev/null +++ b/testdata/monitor_input_stub.json @@ -0,0 +1,32 @@ +{ + "name": "Integration Test Monitor - Count Stub", + "ruleKind": "COUNT", + "definition": { + "compareFunction": "GREATER_OR_EQUAL", + "countAggFunction": "COUNT", + "threshold": 1, + "lookbackTime": "PT5M", + "expressionSummary": "count >= 1 over last 5m", + "rule": { + "count": { + "compareFunction": "GREATER_OR_EQUAL", + "threshold": 1 + } + }, + "inputQuery": { + "outputStage": "query", + "stages": [ + { + "id": "query", + "input": [ + { + "inputName": "main", + "datasetPath": "Default.Observe Agent/Events" + } + ], + "pipeline": "limit 10" + } + ] + } + } +}