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.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. 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