superkick 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- metadata +404 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Integrations
|
|
7
|
+
module Datadog
|
|
8
|
+
# Polls a specific Datadog monitor for status changes and injects events
|
|
9
|
+
# when the alert state transitions (e.g. Alert → OK, Warn → Alert).
|
|
10
|
+
#
|
|
11
|
+
# Typically auto-configured by the AlertSpawner — the monitor_id
|
|
12
|
+
# is passed through the spawn event context.
|
|
13
|
+
#
|
|
14
|
+
# Config keys:
|
|
15
|
+
# monitor_id (required) — the Datadog monitor ID to watch
|
|
16
|
+
# site (optional) — Datadog site, default "datadoghq.com"
|
|
17
|
+
# api_key (optional) — API key, falls back to $DD_API_KEY
|
|
18
|
+
# application_key (optional) — Application key, falls back to $DD_APP_KEY
|
|
19
|
+
#
|
|
20
|
+
# Events emitted:
|
|
21
|
+
# alert_recovered — monitor transitioned to OK
|
|
22
|
+
# alert_escalated — monitor transitioned from Warn to Alert
|
|
23
|
+
# alert_changed — any other status change
|
|
24
|
+
class AlertMonitor < Superkick::Monitor
|
|
25
|
+
attr_reader :conn
|
|
26
|
+
|
|
27
|
+
DEFAULT_SITE = "datadoghq.com"
|
|
28
|
+
|
|
29
|
+
def self.type = :datadog_alert
|
|
30
|
+
|
|
31
|
+
def self.description
|
|
32
|
+
"Monitors a specific Datadog monitor for alert status changes. " \
|
|
33
|
+
"Injects events when the alert recovers, escalates, or changes state."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.required_config = %i[monitor_id]
|
|
37
|
+
|
|
38
|
+
def self.setup_label = "Datadog Alert"
|
|
39
|
+
|
|
40
|
+
def self.setup_config
|
|
41
|
+
<<~YAML
|
|
42
|
+
datadog_alert:
|
|
43
|
+
monitor_id: 12345678 # your Datadog monitor ID
|
|
44
|
+
# site: datadoghq.com # Datadog site (default)
|
|
45
|
+
# api_key: <%= env("DD_API_KEY") %>
|
|
46
|
+
# application_key: <%= env("DD_APP_KEY") %>
|
|
47
|
+
YAML
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.templates_dir
|
|
51
|
+
File.join(__dir__, "templates")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(name:, config:, handler:, agent: nil, server_context: {}, connection: nil)
|
|
55
|
+
super(name:, config:, handler:, agent:, server_context:)
|
|
56
|
+
@conn = connection
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def tick
|
|
60
|
+
monitor = fetch_monitor
|
|
61
|
+
return unless monitor
|
|
62
|
+
|
|
63
|
+
current_status = monitor["overall_state"].to_s
|
|
64
|
+
monitor_name = monitor["name"].to_s
|
|
65
|
+
|
|
66
|
+
if @last_status.nil?
|
|
67
|
+
# First tick — record baseline, don't dispatch
|
|
68
|
+
@last_status = current_status
|
|
69
|
+
Superkick.logger.info(log_tag) { "Initial status: #{current_status}" }
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return if current_status == @last_status
|
|
74
|
+
|
|
75
|
+
previous = @last_status
|
|
76
|
+
@last_status = current_status
|
|
77
|
+
|
|
78
|
+
event_type = classify_transition(previous, current_status)
|
|
79
|
+
|
|
80
|
+
dispatch(
|
|
81
|
+
event_type:,
|
|
82
|
+
monitor_id: self[:monitor_id],
|
|
83
|
+
name: monitor_name,
|
|
84
|
+
status: current_status,
|
|
85
|
+
previous_status: previous,
|
|
86
|
+
alert_type: monitor["type"].to_s,
|
|
87
|
+
query: monitor["query"].to_s,
|
|
88
|
+
tags: monitor["tags"] || [],
|
|
89
|
+
url: monitor_url
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def on_start
|
|
94
|
+
@conn ||= build_connection
|
|
95
|
+
@last_status = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def classify_transition(previous, current)
|
|
101
|
+
if current == "OK"
|
|
102
|
+
:alert_recovered
|
|
103
|
+
elsif current == "Alert" && previous == "Warn"
|
|
104
|
+
:alert_escalated
|
|
105
|
+
else
|
|
106
|
+
:alert_changed
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -- Datadog Monitor API ---------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def fetch_monitor
|
|
113
|
+
monitor_id = self[:monitor_id]
|
|
114
|
+
resp = get("/api/v1/monitor/#{monitor_id}")
|
|
115
|
+
return nil unless resp
|
|
116
|
+
|
|
117
|
+
resp.body
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# -- URL helpers ----------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def monitor_url
|
|
123
|
+
site = self[:site] || DEFAULT_SITE
|
|
124
|
+
"https://app.#{site}/monitors/#{self[:monitor_id]}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# -- HTTP -----------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def get(path)
|
|
130
|
+
resp = @conn.get(path)
|
|
131
|
+
return nil unless handle_response!(resp)
|
|
132
|
+
resp
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_response!(resp)
|
|
136
|
+
case resp.status
|
|
137
|
+
when 200..299 then true
|
|
138
|
+
when 401, 403 then raise FatalError, "Datadog auth failed (HTTP #{resp.status})"
|
|
139
|
+
when 429 then raise RateLimited, "Datadog rate limited"
|
|
140
|
+
when 404
|
|
141
|
+
Superkick.logger.warn(log_tag) { "Datadog monitor #{self[:monitor_id]} not found" }
|
|
142
|
+
false
|
|
143
|
+
else
|
|
144
|
+
Superkick.logger.warn(log_tag) { "Datadog HTTP #{resp.status}" }
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_connection
|
|
150
|
+
site = self[:site] || DEFAULT_SITE
|
|
151
|
+
api_key = self[:api_key] || ENV["DD_API_KEY"]
|
|
152
|
+
app_key = self[:application_key] || ENV["DD_APP_KEY"]
|
|
153
|
+
|
|
154
|
+
Faraday.new(url: "https://api.#{site}") do |f|
|
|
155
|
+
f.headers["DD-API-KEY"] = api_key if api_key
|
|
156
|
+
f.headers["DD-APPLICATION-KEY"] = app_key if app_key
|
|
157
|
+
f.response :json
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Integrations
|
|
7
|
+
module Datadog
|
|
8
|
+
# Watches Datadog Monitors for triggered alerts and spawns AI coding agents
|
|
9
|
+
# to triage them.
|
|
10
|
+
#
|
|
11
|
+
# Polls the Datadog Monitors Search API for monitors in an alerting state.
|
|
12
|
+
# Tracks dispatched monitor IDs in-memory; monitors that recover and re-alert
|
|
13
|
+
# are re-dispatched as new agents.
|
|
14
|
+
#
|
|
15
|
+
# Config keys:
|
|
16
|
+
# site (optional) — Datadog site, default "datadoghq.com"
|
|
17
|
+
# api_key (optional) — API key, falls back to $DD_API_KEY
|
|
18
|
+
# application_key (optional) — Application key, falls back to $DD_APP_KEY
|
|
19
|
+
# statuses (optional) — array of statuses to match, default ["Alert"]
|
|
20
|
+
# tags (optional) — array of tags to filter (AND)
|
|
21
|
+
# monitor_types (optional) — array of monitor types to include
|
|
22
|
+
# priority (optional) — array of priority levels (1-5)
|
|
23
|
+
# query (optional) — raw search query string
|
|
24
|
+
class AlertSpawner < Superkick::Spawner
|
|
25
|
+
attr_reader :conn
|
|
26
|
+
|
|
27
|
+
DEFAULT_SITE = "datadoghq.com"
|
|
28
|
+
DEFAULT_STATUSES = ["Alert"].freeze
|
|
29
|
+
PER_PAGE = 25
|
|
30
|
+
|
|
31
|
+
def self.type = :datadog_alerts
|
|
32
|
+
|
|
33
|
+
def self.description
|
|
34
|
+
"Watches Datadog monitors for triggered alerts and spawns AI coding " \
|
|
35
|
+
"agents to triage them. Supports filtering by status, tags, " \
|
|
36
|
+
"monitor type, and priority."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.required_config = %i[]
|
|
40
|
+
|
|
41
|
+
def self.spawn_templates_dir
|
|
42
|
+
File.join(__dir__, "templates")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.agent_id(event)
|
|
46
|
+
"datadog-alert-#{event[:monitor_id]}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.setup_label = "Datadog Alerts"
|
|
50
|
+
|
|
51
|
+
def self.setup_config
|
|
52
|
+
<<~YAML
|
|
53
|
+
datadog_alerts:
|
|
54
|
+
type: datadog_alerts
|
|
55
|
+
# api_key: <%= env("DD_API_KEY") %>
|
|
56
|
+
# application_key: <%= env("DD_APP_KEY") %>
|
|
57
|
+
# site: datadoghq.com # Datadog site (default)
|
|
58
|
+
# statuses: # alert statuses to watch (default: [Alert])
|
|
59
|
+
# - Alert
|
|
60
|
+
# tags: # filter by tags (AND)
|
|
61
|
+
# - "team:backend"
|
|
62
|
+
# monitor_types: # filter by monitor type
|
|
63
|
+
# - metric
|
|
64
|
+
# max_duration: 3600
|
|
65
|
+
YAML
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(name:, config:, handler:, connection: nil)
|
|
69
|
+
super(name:, config:, handler:)
|
|
70
|
+
@conn = connection
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tick
|
|
74
|
+
monitors = fetch_alerting_monitors
|
|
75
|
+
|
|
76
|
+
# Clear recovered monitors so re-alerts spawn new agents
|
|
77
|
+
current_ids = monitors.map { it["id"] }.to_set
|
|
78
|
+
@seen_monitor_ids &= current_ids
|
|
79
|
+
|
|
80
|
+
new_monitors = monitors.reject { @seen_monitor_ids.include?(it["id"]) }
|
|
81
|
+
|
|
82
|
+
new_monitors.each do |monitor|
|
|
83
|
+
@seen_monitor_ids.add(monitor["id"])
|
|
84
|
+
|
|
85
|
+
dispatch(
|
|
86
|
+
event_type: :alert_triggered,
|
|
87
|
+
monitor_id: monitor["id"],
|
|
88
|
+
name: monitor["name"].to_s,
|
|
89
|
+
status: monitor["overall_state"].to_s,
|
|
90
|
+
alert_type: monitor["type"].to_s,
|
|
91
|
+
query: monitor["query"].to_s,
|
|
92
|
+
message: monitor["message"].to_s,
|
|
93
|
+
tags: monitor["tags"] || [],
|
|
94
|
+
priority: monitor["priority"],
|
|
95
|
+
creator: monitor.dig("creator", "name") || monitor.dig("creator", "email"),
|
|
96
|
+
url: monitor_url(monitor["id"])
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def on_start
|
|
102
|
+
@conn ||= build_connection
|
|
103
|
+
@seen_monitor_ids = Set.new
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# -- Datadog Monitors Search API -------------------------------------------
|
|
109
|
+
|
|
110
|
+
def fetch_alerting_monitors
|
|
111
|
+
query = build_query
|
|
112
|
+
params = {
|
|
113
|
+
"query" => query,
|
|
114
|
+
"per_page" => PER_PAGE.to_s,
|
|
115
|
+
"sort" => "status,asc"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Superkick.logger.debug(log_tag) { "Searching monitors: #{params.inspect}" }
|
|
119
|
+
|
|
120
|
+
resp = get("/api/v1/monitor/search", params)
|
|
121
|
+
return [] unless resp
|
|
122
|
+
|
|
123
|
+
body = resp.body
|
|
124
|
+
monitors = body["monitors"]
|
|
125
|
+
return [] unless monitors.is_a?(Array)
|
|
126
|
+
|
|
127
|
+
Superkick.logger.info(log_tag) { "Found #{monitors.size} alerting monitors" }
|
|
128
|
+
monitors
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_query
|
|
132
|
+
parts = []
|
|
133
|
+
|
|
134
|
+
statuses = self[:statuses] || DEFAULT_STATUSES
|
|
135
|
+
status_clause = statuses.map { it.to_s }.join(" OR ")
|
|
136
|
+
parts << "status:(#{status_clause})"
|
|
137
|
+
|
|
138
|
+
if self[:tags]&.any?
|
|
139
|
+
self[:tags].each { parts << "tag:\"#{it}\"" }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if self[:monitor_types]&.any?
|
|
143
|
+
types = self[:monitor_types].map { "\"#{it}\"" }
|
|
144
|
+
parts << "type:(#{types.join(" OR ")})"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if self[:priority]&.any?
|
|
148
|
+
parts << "priority:(#{self[:priority].join(" OR ")})"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parts << self[:query].to_s if self[:query] && !self[:query].to_s.empty?
|
|
152
|
+
|
|
153
|
+
parts.join(" ")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# -- URL helpers ----------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def monitor_url(monitor_id)
|
|
159
|
+
site = self[:site] || DEFAULT_SITE
|
|
160
|
+
"https://app.#{site}/monitors/#{monitor_id}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# -- HTTP -----------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def get(path, params = {})
|
|
166
|
+
resp = @conn.get(path) do |req|
|
|
167
|
+
params.each { |k, v| req.params[k] = v }
|
|
168
|
+
end
|
|
169
|
+
return nil unless handle_response!(resp)
|
|
170
|
+
resp
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def handle_response!(resp)
|
|
174
|
+
case resp.status
|
|
175
|
+
when 200..299 then true
|
|
176
|
+
when 401, 403 then raise FatalError, "Datadog auth failed (HTTP #{resp.status})"
|
|
177
|
+
when 429 then raise RateLimited, "Datadog rate limited"
|
|
178
|
+
when 404
|
|
179
|
+
Superkick.logger.warn(log_tag) { "Datadog 404: resource not found" }
|
|
180
|
+
false
|
|
181
|
+
else
|
|
182
|
+
Superkick.logger.warn(log_tag) { "Datadog HTTP #{resp.status}" }
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_connection
|
|
188
|
+
site = self[:site] || DEFAULT_SITE
|
|
189
|
+
api_key = self[:api_key] || ENV["DD_API_KEY"]
|
|
190
|
+
app_key = self[:application_key] || ENV["DD_APP_KEY"]
|
|
191
|
+
|
|
192
|
+
Faraday.new(url: "https://api.#{site}") do |f|
|
|
193
|
+
f.headers["DD-API-KEY"] = api_key if api_key
|
|
194
|
+
f.headers["DD-APPLICATION-KEY"] = app_key if app_key
|
|
195
|
+
f.response :json
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{% event_title %}Superkick: {{ event_type | format_title }}{% endevent_title %}
|
|
2
|
+
{% event_text %}{{ message }}{% endevent_text %}
|
|
3
|
+
{% alert_type %}{{ event_type | datadog_alert_type }}{% endalert_type %}
|
|
4
|
+
{% tag %}event_type:{{ event_type }}{% endtag %}
|
|
5
|
+
{% if agent_id %}{% tag %}agent_id:{{ agent_id }}{% endtag %}{% endif %}
|
|
6
|
+
{% if monitor %}{% tag %}monitor_type:{{ monitor.type }}{% endtag %}
|
|
7
|
+
{% tag %}monitor_name:{{ monitor.name }}{% endtag %}{% endif %}
|
|
8
|
+
{% if team %}{% tag %}team_id:{{ team.id }}{% endtag %}{% endif %}
|
|
9
|
+
{% if agent.role %}{% tag %}team_role:{{ agent.role }}{% endtag %}{% endif %}
|
|
10
|
+
{% if spawner %}{% tag %}spawner_name:{{ spawner.name }}{% endtag %}{% endif %}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Integrations
|
|
8
|
+
module Datadog
|
|
9
|
+
# Sends events and metrics to Datadog via DogStatsD (UDP).
|
|
10
|
+
#
|
|
11
|
+
# DogStatsD is the native way to get custom events and metrics into Datadog
|
|
12
|
+
# without going through the HTTP API. It requires a local Datadog Agent
|
|
13
|
+
# running with DogStatsD enabled (the default).
|
|
14
|
+
#
|
|
15
|
+
# Events appear in the Datadog Event Explorer; metrics appear in Metrics
|
|
16
|
+
# Explorer and can power dashboards and monitors.
|
|
17
|
+
#
|
|
18
|
+
# Stateful: tracks `spawned_at` per agent to compute duration on terminal
|
|
19
|
+
# events, and emits a timing metric when agents finish.
|
|
20
|
+
#
|
|
21
|
+
# Template support:
|
|
22
|
+
# Event formatting (title, text, alert type, tags) is defined via Liquid
|
|
23
|
+
# templates with custom block tags. Users can override per-event-type
|
|
24
|
+
# or the default template.
|
|
25
|
+
#
|
|
26
|
+
# Block tags: {% event_title %}, {% event_text %}, {% alert_type %}, {% tag %}
|
|
27
|
+
# Filters: `datadog_alert_type`, `format_title`
|
|
28
|
+
#
|
|
29
|
+
# Templates are resolved from:
|
|
30
|
+
# 1. ~/.superkick/templates/notifications/datadog/<event_type>.liquid
|
|
31
|
+
# 2. <bundled>/notification_templates/<event_type>.liquid
|
|
32
|
+
# 3. ~/.superkick/templates/notifications/datadog/default.liquid
|
|
33
|
+
# 4. <bundled>/notification_templates/default.liquid
|
|
34
|
+
#
|
|
35
|
+
# Configuration:
|
|
36
|
+
#
|
|
37
|
+
# notifications:
|
|
38
|
+
# - type: datadog
|
|
39
|
+
# statsd_host: localhost # default
|
|
40
|
+
# statsd_port: 8125 # default
|
|
41
|
+
# prefix: superkick # metric name prefix (default)
|
|
42
|
+
# tags: # additional global tags
|
|
43
|
+
# - "env:production"
|
|
44
|
+
#
|
|
45
|
+
# Emitted metrics (tagged with low-cardinality dimensions only):
|
|
46
|
+
# - <prefix>.event — counter, incremented on every event
|
|
47
|
+
# - <prefix>.agent.duration — gauge (seconds), on terminal events only
|
|
48
|
+
# - <prefix>.agent.cost_usd — gauge (dollars), when cost data is present
|
|
49
|
+
#
|
|
50
|
+
# Tag cardinality: Events include all tags (agent_id, team_id, etc.) since
|
|
51
|
+
# Datadog events don't create custom metric time series. Metrics only include
|
|
52
|
+
# low-cardinality tags (event_type, monitor_type, monitor_name, team_role,
|
|
53
|
+
# spawner_name) to avoid billing explosions from unbounded dimensions.
|
|
54
|
+
class Notifier < Superkick::Notifier
|
|
55
|
+
def self.type = :datadog
|
|
56
|
+
|
|
57
|
+
def self.templates_dir = File.expand_path("notification_templates", __dir__)
|
|
58
|
+
|
|
59
|
+
def self.setup_label = "Datadog"
|
|
60
|
+
|
|
61
|
+
def self.setup_config
|
|
62
|
+
<<~YAML
|
|
63
|
+
- type: datadog
|
|
64
|
+
# statsd_host: localhost # DogStatsD host (default)
|
|
65
|
+
# statsd_port: 8125 # DogStatsD port (default)
|
|
66
|
+
# prefix: superkick # metric name prefix (default)
|
|
67
|
+
# tags: # additional global tags
|
|
68
|
+
# - "env:production"
|
|
69
|
+
YAML
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
ALERT_TYPE_MAP = {
|
|
73
|
+
"agent_completed" => "success",
|
|
74
|
+
"agent_failed" => "error",
|
|
75
|
+
"agent_timed_out" => "warning",
|
|
76
|
+
"agent_blocked" => "warning",
|
|
77
|
+
"agent_stalled" => "warning",
|
|
78
|
+
"agent_terminated" => "warning",
|
|
79
|
+
"agent_spawned" => "info",
|
|
80
|
+
"agent_claimed" => "info",
|
|
81
|
+
"agent_unclaimed" => "info",
|
|
82
|
+
"agent_pending_approval" => "info",
|
|
83
|
+
"workflow_triggered" => "info",
|
|
84
|
+
"workflow_iterations_exceeded" => "warning",
|
|
85
|
+
"budget_warning" => "warning",
|
|
86
|
+
"budget_exceeded" => "error",
|
|
87
|
+
"team_created" => "info",
|
|
88
|
+
"team_completed" => "success",
|
|
89
|
+
"team_failed" => "error",
|
|
90
|
+
"team_timed_out" => "warning",
|
|
91
|
+
"worker_spawned" => "info",
|
|
92
|
+
"teammate_message" => "info",
|
|
93
|
+
"teammate_blocker" => "warning",
|
|
94
|
+
"attach_promoted" => "info",
|
|
95
|
+
"attach_demoted" => "info",
|
|
96
|
+
"attach_idle_timeout" => "warning",
|
|
97
|
+
"attach_force_takeover" => "warning",
|
|
98
|
+
"agent_update" => "info",
|
|
99
|
+
"artifact_published" => "info"
|
|
100
|
+
}.freeze
|
|
101
|
+
|
|
102
|
+
liquid do
|
|
103
|
+
context do
|
|
104
|
+
attribute :title
|
|
105
|
+
attribute :text
|
|
106
|
+
attribute :tags, default: -> { [] }
|
|
107
|
+
attribute :alert_type
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
filter :datadog_alert_type do |event_type|
|
|
111
|
+
Superkick::Integrations::Datadog::Notifier::ALERT_TYPE_MAP.fetch(event_type.to_s, "info")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
filter :format_title do |event_type|
|
|
115
|
+
event_type.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
block :event_title do |ctx, text, _attrs|
|
|
119
|
+
ctx.title = text
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
block :event_text do |ctx, text, _attrs|
|
|
123
|
+
ctx.text = text
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
block :alert_type do |ctx, text, _attrs|
|
|
127
|
+
ctx.alert_type = text
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
block :tag do |ctx, text, _attrs|
|
|
131
|
+
ctx.tags << text unless text.empty?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def initialize(statsd_host: "localhost", statsd_port: 8125, prefix: "superkick", tags: [], **opts)
|
|
136
|
+
super(**opts)
|
|
137
|
+
@host = statsd_host
|
|
138
|
+
@port = Integer(statsd_port)
|
|
139
|
+
@prefix = prefix
|
|
140
|
+
@global_tags = Array(tags)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def stateful? = true
|
|
144
|
+
|
|
145
|
+
def agent_finished(agent_id:)
|
|
146
|
+
@state_store.delete(:datadog, agent_id.to_s)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def notify(payload)
|
|
150
|
+
event_type = payload[:event_type]
|
|
151
|
+
agent_id = payload[:agent_id]
|
|
152
|
+
metric_tags = build_metric_tags(payload)
|
|
153
|
+
|
|
154
|
+
# Track spawn time for duration computation
|
|
155
|
+
if event_type == "agent_spawned" && agent_id && !agent_id.empty?
|
|
156
|
+
@state_store.put(:datadog, agent_id, {spawned_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)}) unless @state_store.get(:datadog, agent_id)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Send the DogStatsD event (template-driven)
|
|
160
|
+
send_event(payload)
|
|
161
|
+
|
|
162
|
+
# Always increment the event counter (low-cardinality tags only)
|
|
163
|
+
send_metric("#{@prefix}.event", 1, :counter, metric_tags)
|
|
164
|
+
|
|
165
|
+
# On terminal events, emit duration — prefer agent.spawned_at, fall back to local tracking
|
|
166
|
+
if terminal_event?(event_type) && agent_id && !agent_id.empty?
|
|
167
|
+
agent_drop = payload.dig(:context, :agent)
|
|
168
|
+
duration = if agent_drop&.spawned_at
|
|
169
|
+
(Time.now.utc - Time.parse(agent_drop.spawned_at)).round(1)
|
|
170
|
+
else
|
|
171
|
+
state = @state_store.get(:datadog, agent_id)
|
|
172
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - state[:spawned_at]).round(1) if state&.dig(:spawned_at)
|
|
173
|
+
end
|
|
174
|
+
send_metric("#{@prefix}.agent.duration", duration, :gauge, metric_tags) if duration
|
|
175
|
+
|
|
176
|
+
# Emit cost gauge when available
|
|
177
|
+
if agent_drop&.cost_usd
|
|
178
|
+
send_metric("#{@prefix}.agent.cost_usd", agent_drop.cost_usd, :gauge, metric_tags)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
rescue => e
|
|
182
|
+
Superkick.logger.warn("notifier:datadog") { "Failed: #{e.message}" }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def send_event(payload)
|
|
188
|
+
template_result = render_notification(payload)
|
|
189
|
+
|
|
190
|
+
if template_result && template_result[:structured]
|
|
191
|
+
structured = template_result[:structured]
|
|
192
|
+
title = structured.title || "Superkick: #{format_title(payload[:event_type])}"
|
|
193
|
+
text = structured.text || payload[:message].to_s
|
|
194
|
+
alert_type = structured.alert_type || ALERT_TYPE_MAP.fetch(payload[:event_type], "info")
|
|
195
|
+
tags = @global_tags + structured.tags
|
|
196
|
+
else
|
|
197
|
+
title = "Superkick: #{format_title(payload[:event_type])}"
|
|
198
|
+
text = payload[:message].to_s
|
|
199
|
+
alert_type = ALERT_TYPE_MAP.fetch(payload[:event_type], "info")
|
|
200
|
+
tags = @global_tags + build_event_tags_fallback(payload)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# DogStatsD event datagram format:
|
|
204
|
+
# _e{<TITLE_UTF8_LENGTH>,<TEXT_UTF8_LENGTH>}:<TITLE>|<TEXT>|t:<ALERT_TYPE>|#<TAGS>
|
|
205
|
+
tag_str = tags.join(",")
|
|
206
|
+
datagram = "_e{#{title.bytesize},#{text.bytesize}}:#{title}|#{text}|t:#{alert_type}"
|
|
207
|
+
datagram += "|##{tag_str}" unless tag_str.empty?
|
|
208
|
+
send_udp(datagram)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def send_metric(name, value, type, tags)
|
|
212
|
+
# DogStatsD metric datagram format:
|
|
213
|
+
# <METRIC_NAME>:<VALUE>|<TYPE>|#<TAGS>
|
|
214
|
+
type_char = (type == :counter) ? "c" : "g"
|
|
215
|
+
tag_str = tags.join(",")
|
|
216
|
+
datagram = "#{name}:#{value}|#{type_char}"
|
|
217
|
+
datagram += "|##{tag_str}" unless tag_str.empty?
|
|
218
|
+
send_udp(datagram)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def send_udp(datagram)
|
|
222
|
+
socket = UDPSocket.new
|
|
223
|
+
socket.send(datagram, 0, @host, @port)
|
|
224
|
+
ensure
|
|
225
|
+
socket&.close
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Fallback event tags when no template is available.
|
|
229
|
+
def build_event_tags_fallback(payload)
|
|
230
|
+
tags = []
|
|
231
|
+
tags << "event_type:#{payload[:event_type]}" unless payload[:event_type].to_s.empty?
|
|
232
|
+
tags << "agent_id:#{payload[:agent_id]}" unless payload[:agent_id].to_s.empty?
|
|
233
|
+
|
|
234
|
+
monitor = payload[:monitor]
|
|
235
|
+
if monitor
|
|
236
|
+
tags << "monitor_type:#{monitor.type}" unless monitor.type.to_s.empty?
|
|
237
|
+
tags << "monitor_name:#{monitor.name}" unless monitor.name.to_s.empty?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
ctx = payload[:context] || {}
|
|
241
|
+
team = ctx[:team]
|
|
242
|
+
tags << "team_id:#{team.id}" if team&.id
|
|
243
|
+
agent = ctx[:agent]
|
|
244
|
+
tags << "team_role:#{agent.role}" if agent&.role
|
|
245
|
+
spawner = ctx[:spawner]
|
|
246
|
+
tags << "spawner_name:#{spawner.name}" if spawner
|
|
247
|
+
|
|
248
|
+
tags
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Low-cardinality tags only — used on counters and gauges where each unique
|
|
252
|
+
# tag combination creates a custom metric time series in Datadog.
|
|
253
|
+
#
|
|
254
|
+
# Safe dimensions:
|
|
255
|
+
# event_type — ~20 values (lifecycle + monitor events)
|
|
256
|
+
# monitor_type — ~5 values (github, circleci, shortcut, shell, bugsnag)
|
|
257
|
+
# monitor_name — bounded by config keys
|
|
258
|
+
# team_role — 2 values (lead, worker)
|
|
259
|
+
# spawner_name — bounded by config keys
|
|
260
|
+
#
|
|
261
|
+
# Excluded from metrics (unbounded cardinality):
|
|
262
|
+
# agent_id — unique per agent session
|
|
263
|
+
# team_id — unique per team session
|
|
264
|
+
def build_metric_tags(payload)
|
|
265
|
+
tags = @global_tags.dup
|
|
266
|
+
tags << "event_type:#{payload[:event_type]}" unless payload[:event_type].to_s.empty?
|
|
267
|
+
|
|
268
|
+
monitor = payload[:monitor]
|
|
269
|
+
if monitor
|
|
270
|
+
tags << "monitor_type:#{monitor.type}" unless monitor.type.to_s.empty?
|
|
271
|
+
tags << "monitor_name:#{monitor.name}" unless monitor.name.to_s.empty?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
ctx = payload[:context] || {}
|
|
275
|
+
agent = ctx[:agent]
|
|
276
|
+
tags << "team_role:#{agent.role}" if agent&.role
|
|
277
|
+
spawner = ctx[:spawner]
|
|
278
|
+
tags << "spawner_name:#{spawner.name}" if spawner
|
|
279
|
+
|
|
280
|
+
tags
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def format_title(event_type)
|
|
284
|
+
event_type.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def terminal_event?(event_type)
|
|
288
|
+
%w[agent_completed agent_failed agent_timed_out agent_terminated
|
|
289
|
+
team_completed team_failed team_timed_out].include?(event_type)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|