Skip to content

Commit 4d2f520

Browse files
lewispbclaude
andcommitted
Add native OpenTelemetry support for deploy logs
Ship all deploy output and structured deploy events to an OTLP HTTP endpoint. When configured via `otel.endpoint` in deploy.yml, Kamal streams every line of stdout/stderr to the collector and emits deploy.start/deploy.complete events with version, destination, runtime, and performer metadata. Resource attributes set automatically: - service.name: "kamal" - service.namespace: <service name from deploy.yml> - deployment.environment.name: <destination> - deploy.performer: <git user> Usage in deploy.yml: otel: endpoint: http://otel-gateway:4318 Logs flow through the existing OTLP logs pipeline on the collector — no new ports or receiver config needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 453d8d7 commit 4d2f520

File tree

9 files changed

+318
-2
lines changed

9 files changed

+318
-2
lines changed

lib/kamal/cli/main.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ def setup
1919
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
2020
option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache"
2121
def deploy(boot_accessories: false)
22+
KAMAL.otel_event("deploy.start")
23+
2224
runtime = print_runtime do
2325
invoke_options = deploy_options
2426

@@ -48,13 +50,21 @@ def deploy(boot_accessories: false)
4850
end
4951
end
5052

53+
KAMAL.otel_event("deploy.complete", runtime: runtime.round.to_s)
5154
run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s
55+
rescue Exception => e
56+
KAMAL.otel_event("deploy.failed", error: e.message)
57+
raise
58+
ensure
59+
KAMAL.otel_shutdown
5260
end
5361

5462
desc "redeploy", "Deploy app to servers without bootstrapping servers, starting kamal-proxy and pruning"
5563
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
5664
option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache"
5765
def redeploy
66+
KAMAL.otel_event("deploy.start", command: "redeploy")
67+
5868
runtime = print_runtime do
5969
invoke_options = deploy_options
6070

@@ -76,12 +86,20 @@ def redeploy
7686
end
7787
end
7888

89+
KAMAL.otel_event("deploy.complete", runtime: runtime.round.to_s, command: "redeploy")
7990
run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s
91+
rescue => e
92+
KAMAL.otel_event("deploy.failed", error: e.message, command: "redeploy")
93+
raise
94+
ensure
95+
KAMAL.otel_shutdown
8096
end
8197

8298
desc "rollback [VERSION]", "Rollback app to VERSION"
8399
def rollback(version)
84100
rolled_back = false
101+
KAMAL.otel_event("deploy.start", command: "rollback", rollback_version: version)
102+
85103
runtime = print_runtime do
86104
with_lock do
87105
invoke_options = deploy_options
@@ -100,7 +118,13 @@ def rollback(version)
100118
end
101119
end
102120

121+
KAMAL.otel_event("deploy.complete", runtime: runtime.round.to_s, command: "rollback") if rolled_back
103122
run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s if rolled_back
123+
rescue => e
124+
KAMAL.otel_event("deploy.failed", error: e.message, command: "rollback")
125+
raise
126+
ensure
127+
KAMAL.otel_shutdown
104128
end
105129

106130
desc "details", "Show details about all containers"

lib/kamal/commander.rb

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
class Kamal::Commander
66
attr_accessor :verbosity, :holding_lock, :connected
7-
attr_reader :specific_roles, :specific_hosts
7+
attr_reader :specific_roles, :specific_hosts, :otel_shipper
88
delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics
99

1010
def initialize
@@ -142,6 +142,19 @@ def with_verbosity(level)
142142
SSHKit.config.output_verbosity = old_level
143143
end
144144

145+
def otel_enabled?
146+
@otel_shipper.present?
147+
end
148+
149+
def otel_event(name, **attrs)
150+
@otel_shipper&.event(name, **attrs)
151+
end
152+
153+
def otel_shutdown
154+
@otel_shipper&.shutdown
155+
@otel_shipper = nil
156+
end
157+
145158
def holding_lock?
146159
self.holding_lock
147160
end
@@ -161,6 +174,23 @@ def configure_sshkit_with(config)
161174
end
162175
SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs
163176
SSHKit.config.output_verbosity = verbosity
177+
178+
configure_otel_with(config)
179+
end
180+
181+
def configure_otel_with(config)
182+
return unless config.otel.enabled?
183+
184+
@otel_shipper = Kamal::OtelShipper.new(
185+
endpoint: config.otel.endpoint,
186+
service_namespace: config.otel.service_namespace,
187+
environment: config.otel.environment,
188+
version: config.version,
189+
performer: `git config user.name`.chomp.presence || ENV["USER"]
190+
)
191+
192+
$stdout = Kamal::TeeIo.new($stdout, @otel_shipper)
193+
$stderr = Kamal::TeeIo.new($stderr, @otel_shipper)
164194
end
165195

166196
def specifics

lib/kamal/configuration.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class Kamal::Configuration
1212
delegate :argumentize, :optionize, to: Kamal::Utils
1313

1414
attr_reader :destination, :raw_config, :secrets
15-
attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry
15+
attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :otel, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry
1616

1717
include Validation
1818

@@ -71,6 +71,7 @@ def initialize(raw_config, destination: nil, version: nil, validate: true)
7171
@env = Env.new(config: @raw_config.env || {}, secrets: secrets)
7272

7373
@logging = Logging.new(logging_config: @raw_config.logging)
74+
@otel = Otel.new(config: self)
7475
@proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)
7576
@proxy_boot = Proxy::Boot.new(config: self)
7677
@ssh = Ssh.new(config: self)

lib/kamal/configuration/docs/configuration.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ boot:
206206
logging:
207207
...
208208

209+
# OpenTelemetry
210+
#
211+
# Ship deploy logs to an OTel endpoint, see kamal docs otel:
212+
otel:
213+
...
214+
209215
# Aliases
210216
#
211217
# Alias configuration, see kamal docs alias:
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# OpenTelemetry
2+
#
3+
# Ship deploy logs to an OpenTelemetry-compatible endpoint via OTLP HTTP.
4+
#
5+
# When configured, Kamal streams all deploy output and emits structured
6+
# deploy events (start, complete, failed) to the specified endpoint.
7+
#
8+
# Logs are sent as OTLP log records with resource attributes:
9+
# - service.name: "kamal"
10+
# - service.namespace: <service name from config>
11+
# - deployment.environment.name: <destination>
12+
13+
# OTel options
14+
#
15+
# The options are specified under the otel key in the configuration file.
16+
otel:
17+
18+
# Endpoint
19+
#
20+
# The OTLP HTTP endpoint to send logs to (required to enable).
21+
# This should be the base URL of the OTel collector, e.g. http://otel-gateway:4318
22+
endpoint: http://otel-gateway:4318

lib/kamal/configuration/otel.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
class Kamal::Configuration::Otel
2+
include Kamal::Configuration::Validation
3+
4+
attr_reader :otel_config
5+
6+
def initialize(config:)
7+
@otel_config = config.raw_config.otel || {}
8+
@service = config.service
9+
@destination = config.destination
10+
validate! otel_config unless otel_config.empty?
11+
end
12+
13+
def enabled?
14+
endpoint.present?
15+
end
16+
17+
def endpoint
18+
otel_config["endpoint"]
19+
end
20+
21+
def service_namespace
22+
@service
23+
end
24+
25+
def environment
26+
@destination
27+
end
28+
29+
def to_h
30+
otel_config
31+
end
32+
end

lib/kamal/otel_shipper.rb

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
require "net/http"
2+
require "json"
3+
require "uri"
4+
5+
class Kamal::OtelShipper
6+
BATCH_SIZE = 100
7+
FLUSH_INTERVAL = 5 # seconds
8+
9+
def initialize(endpoint:, service_namespace:, environment:, version:, performer: nil)
10+
@endpoint = URI("#{endpoint}/v1/logs")
11+
@service_namespace = service_namespace
12+
@environment = environment || "unknown"
13+
@version = version
14+
@performer = performer || ENV["USER"] || "unknown"
15+
@buffer = Queue.new
16+
@flush_mutex = Mutex.new
17+
@running = true
18+
@thread = start_flush_thread
19+
end
20+
21+
def <<(str)
22+
return self unless @running
23+
str.to_s.each_line do |line|
24+
stripped = line.chomp
25+
@buffer << stripped unless stripped.empty?
26+
end
27+
flush if @buffer.size >= BATCH_SIZE
28+
self
29+
end
30+
31+
def event(name, **attributes)
32+
attrs = attributes.map { |k, v| { key: k.to_s, value: { stringValue: v.to_s } } }
33+
records = [ {
34+
timeUnixNano: time_ns,
35+
severityNumber: 9,
36+
severityText: "INFO",
37+
body: { stringValue: name },
38+
attributes: attrs
39+
} ]
40+
ship_records(records)
41+
end
42+
43+
def flush
44+
@flush_mutex.synchronize do
45+
lines = drain_buffer
46+
return if lines.empty?
47+
48+
lines.each_slice(BATCH_SIZE) { |batch| ship_lines(batch) }
49+
end
50+
end
51+
52+
def shutdown
53+
@running = false
54+
@thread&.kill
55+
flush
56+
end
57+
58+
private
59+
def start_flush_thread
60+
Thread.new do
61+
while @running
62+
sleep FLUSH_INTERVAL
63+
flush
64+
end
65+
end
66+
end
67+
68+
def drain_buffer
69+
lines = []
70+
lines << @buffer.pop(true) until @buffer.empty?
71+
lines
72+
rescue ThreadError
73+
lines
74+
end
75+
76+
def ship_lines(lines)
77+
records = lines.map do |line|
78+
{
79+
timeUnixNano: time_ns,
80+
severityNumber: 9,
81+
severityText: "INFO",
82+
body: { stringValue: line }
83+
}
84+
end
85+
ship_records(records)
86+
end
87+
88+
def ship_records(records)
89+
payload = {
90+
resourceLogs: [ {
91+
resource: { attributes: resource_attributes },
92+
scopeLogs: [ { logRecords: records } ]
93+
} ]
94+
}
95+
96+
http = Net::HTTP.new(@endpoint.host, @endpoint.port)
97+
http.use_ssl = @endpoint.scheme == "https"
98+
http.open_timeout = 2
99+
http.read_timeout = 5
100+
req = Net::HTTP::Post.new(@endpoint.path, "Content-Type" => "application/json")
101+
req.body = JSON.generate(payload)
102+
http.request(req)
103+
rescue
104+
# Best effort — never fail the deploy
105+
end
106+
107+
def resource_attributes
108+
[
109+
{ key: "service.name", value: { stringValue: "kamal" } },
110+
{ key: "service.namespace", value: { stringValue: @service_namespace } },
111+
{ key: "service.version", value: { stringValue: @version } },
112+
{ key: "deployment.environment.name", value: { stringValue: @environment } },
113+
{ key: "deploy.performer", value: { stringValue: @performer } }
114+
]
115+
end
116+
117+
def time_ns
118+
(Time.now.to_f * 1_000_000_000).to_i.to_s
119+
end
120+
end

lib/kamal/tee_io.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
class Kamal::TeeIo
2+
def initialize(original, shipper)
3+
@original = original
4+
@shipper = shipper
5+
end
6+
7+
def write(str)
8+
@shipper << str
9+
@original.write(str)
10+
end
11+
12+
def <<(str)
13+
@shipper << str
14+
@original << str
15+
self
16+
end
17+
18+
def puts(*args)
19+
str = args.empty? ? "\n" : args.map(&:to_s).join("\n") + "\n"
20+
@shipper << str
21+
@original.puts(*args)
22+
end
23+
24+
def print(*args)
25+
str = args.join
26+
@shipper << str
27+
@original.print(*args)
28+
end
29+
30+
def flush
31+
@original.flush if @original.respond_to?(:flush)
32+
end
33+
34+
def method_missing(method, *args, &block)
35+
@original.send(method, *args, &block)
36+
end
37+
38+
def respond_to_missing?(method, include_private = false)
39+
@original.respond_to?(method, include_private)
40+
end
41+
end

0 commit comments

Comments
 (0)