activejob-temporal 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 (99) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +130 -0
  3. data/LICENSE +21 -0
  4. data/README.md +198 -0
  5. data/activejob-temporal.gemspec +58 -0
  6. data/api/job_payload_schema.json +318 -0
  7. data/bin/temporal-worker +295 -0
  8. data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
  9. data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
  10. data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
  11. data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
  12. data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
  13. data/lib/activejob/temporal/adapter.rb +257 -0
  14. data/lib/activejob/temporal/audit_log.rb +118 -0
  15. data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
  16. data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
  17. data/lib/activejob/temporal/bind_policy.rb +44 -0
  18. data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
  19. data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
  20. data/lib/activejob/temporal/cancel.rb +236 -0
  21. data/lib/activejob/temporal/certificate_watcher.rb +76 -0
  22. data/lib/activejob/temporal/chain_options.rb +83 -0
  23. data/lib/activejob/temporal/child_workflow_options.rb +102 -0
  24. data/lib/activejob/temporal/client.rb +215 -0
  25. data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
  26. data/lib/activejob/temporal/configurable.rb +55 -0
  27. data/lib/activejob/temporal/configuration.rb +981 -0
  28. data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
  29. data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
  30. data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
  31. data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
  32. data/lib/activejob/temporal/dependency_options.rb +134 -0
  33. data/lib/activejob/temporal/external_operation.rb +193 -0
  34. data/lib/activejob/temporal/health_check_server.rb +159 -0
  35. data/lib/activejob/temporal/http_line_reader.rb +36 -0
  36. data/lib/activejob/temporal/inspect.rb +184 -0
  37. data/lib/activejob/temporal/job_descriptor.rb +37 -0
  38. data/lib/activejob/temporal/job_payload_builder.rb +209 -0
  39. data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
  40. data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
  41. data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
  42. data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
  43. data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
  44. data/lib/activejob/temporal/job_tags.rb +40 -0
  45. data/lib/activejob/temporal/locales/en.yml +126 -0
  46. data/lib/activejob/temporal/logger.rb +214 -0
  47. data/lib/activejob/temporal/metrics_server.rb +150 -0
  48. data/lib/activejob/temporal/middleware/chain.rb +106 -0
  49. data/lib/activejob/temporal/middleware.rb +11 -0
  50. data/lib/activejob/temporal/observability/datadog.rb +167 -0
  51. data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
  52. data/lib/activejob/temporal/observability/prometheus.rb +271 -0
  53. data/lib/activejob/temporal/observability.rb +260 -0
  54. data/lib/activejob/temporal/payload.rb +415 -0
  55. data/lib/activejob/temporal/payload_encryption.rb +215 -0
  56. data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
  57. data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
  58. data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
  59. data/lib/activejob/temporal/payload_serializers.rb +37 -0
  60. data/lib/activejob/temporal/payload_storage.rb +103 -0
  61. data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
  62. data/lib/activejob/temporal/rate_limit_options.rb +94 -0
  63. data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
  64. data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
  65. data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
  66. data/lib/activejob/temporal/retry_mapper.rb +264 -0
  67. data/lib/activejob/temporal/schedulable.rb +60 -0
  68. data/lib/activejob/temporal/schedule.rb +181 -0
  69. data/lib/activejob/temporal/schedule_options.rb +105 -0
  70. data/lib/activejob/temporal/search_attributes.rb +173 -0
  71. data/lib/activejob/temporal/signal_query.rb +161 -0
  72. data/lib/activejob/temporal/signal_query_options.rb +106 -0
  73. data/lib/activejob/temporal/temporal_options.rb +114 -0
  74. data/lib/activejob/temporal/tls_file.rb +45 -0
  75. data/lib/activejob/temporal/transaction_safety.rb +39 -0
  76. data/lib/activejob/temporal/version.rb +7 -0
  77. data/lib/activejob/temporal/visibility_query.rb +13 -0
  78. data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
  79. data/lib/activejob/temporal/worker_health.rb +117 -0
  80. data/lib/activejob/temporal/worker_pool.rb +408 -0
  81. data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
  82. data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
  83. data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
  84. data/lib/activejob/temporal/workflow_identity.rb +62 -0
  85. data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
  86. data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
  87. data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
  88. data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
  89. data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
  90. data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
  91. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
  92. data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
  93. data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
  94. data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
  95. data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
  96. data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
  97. data/lib/activejob/temporal.rb +297 -0
  98. data/lib/activejob-temporal.rb +3 -0
  99. metadata +423 -0
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ # Structured logging for activejob-temporal gem.
9
+ #
10
+ # This module provides structured JSON logging with event names and typed attributes.
11
+ # It integrates with SemanticLogger if available, otherwise falls back to standard
12
+ # Ruby Logger with JSON formatting.
13
+ #
14
+ # All log entries include:
15
+ # - event: Event name (String or Symbol)
16
+ # - timestamp: ISO8601 UTC timestamp
17
+ # - Custom attributes (Hash)
18
+ #
19
+ # @note SemanticLogger Detection
20
+ # If the configured logger is a SemanticLogger instance, log entries are passed directly
21
+ # as hashes. Otherwise, the module JSON-stringifies the payload before passing it to the
22
+ # configured Ruby Logger instance.
23
+ #
24
+ # @example Basic logging
25
+ # Logger.info("job.enqueued", job_id: "123", queue: "default")
26
+ # # => { "event": "job.enqueued", "timestamp": "2025-10-29T12:00:00Z", "job_id": "123", "queue": "default" }
27
+ #
28
+ # @example Error logging
29
+ # Logger.error("job.failed", job_id: "123", error: "NetworkError")
30
+ #
31
+ # @example With SemanticLogger
32
+ # # If the configured logger is SemanticLogger, structured hash is passed directly
33
+ # Logger.info("workflow.started", workflow_id: "wf-123")
34
+ # # SemanticLogger formats as structured JSON
35
+ #
36
+ # @example Without SemanticLogger
37
+ # # Falls back to JSON.generate before calling logger.info
38
+ # Logger.info("workflow.started", workflow_id: "wf-123")
39
+ # # => '{"event":"workflow.started","timestamp":"2025-10-31T12:00:00Z","workflow_id":"wf-123"}'
40
+ module Logger
41
+ extend self
42
+
43
+ CONTROL_CHARACTER_PATTERN = /[[:cntrl:]]/
44
+
45
+ # Logs an event at INFO level.
46
+ #
47
+ # @param event_name [String, Symbol] Name of the event (e.g., "job.enqueued")
48
+ # @param attributes [Hash] Additional structured data to include in log entry
49
+ # @return [void]
50
+ # @raise [ArgumentError] if event_name is not a String or Symbol
51
+ # @raise [ArgumentError] if attributes is not a Hash
52
+ # @raise [NoMethodError] if logger is not configured
53
+ # @example
54
+ # Logger.log_event("workflow.started", workflow_id: "wf-123", job_class: "MyJob")
55
+ #
56
+ # @example With complex attributes
57
+ # Logger.log_event("job.completed", {
58
+ # job_id: "abc-123",
59
+ # duration_ms: 1500,
60
+ # result: { success: true, records_processed: 100 }
61
+ # })
62
+ def log_event(event_name, attributes = {})
63
+ log(:info, event_name, attributes)
64
+ end
65
+
66
+ # Logs an event at INFO level.
67
+ #
68
+ # @param event_name [String, Symbol] Name of the event
69
+ # @param attributes [Hash] Additional structured data
70
+ # @return [void]
71
+ # @raise [ArgumentError] if event_name is not a String or Symbol
72
+ # @raise [ArgumentError] if attributes is not a Hash
73
+ # @raise [NoMethodError] if logger is not configured
74
+ # @example
75
+ # Logger.info("job.completed", job_id: "123", duration_ms: 1500)
76
+ def info(event_name, attributes = {})
77
+ log(:info, event_name, attributes)
78
+ end
79
+
80
+ # Logs an event at WARN level.
81
+ #
82
+ # @param event_name [String, Symbol] Name of the event
83
+ # @param attributes [Hash] Additional structured data
84
+ # @return [void]
85
+ # @raise [ArgumentError] if event_name is not a String or Symbol
86
+ # @raise [ArgumentError] if attributes is not a Hash
87
+ # @raise [NoMethodError] if logger is not configured
88
+ # @example
89
+ # Logger.warn("job.retry", job_id: "123", attempt: 2, error: "Timeout")
90
+ def warn(event_name, attributes = {})
91
+ log(:warn, event_name, attributes)
92
+ end
93
+
94
+ # Logs an event at ERROR level.
95
+ #
96
+ # @param event_name [String, Symbol] Name of the event
97
+ # @param attributes [Hash] Additional structured data
98
+ # @return [void]
99
+ # @raise [ArgumentError] if event_name is not a String or Symbol
100
+ # @raise [ArgumentError] if attributes is not a Hash
101
+ # @raise [NoMethodError] if logger is not configured
102
+ # @example
103
+ # Logger.error("job.failed", job_id: "123", error_class: "RuntimeError", message: "Boom")
104
+ def error(event_name, attributes = {})
105
+ log(:error, event_name, attributes)
106
+ end
107
+
108
+ def log_to(configured_logger, level, event_name, attributes = {})
109
+ log(level, event_name, attributes, configured_logger: configured_logger)
110
+ end
111
+
112
+ private
113
+
114
+ # Internal logging method that handles all log levels.
115
+ # @api private
116
+ def log(level, event_name, attributes, configured_logger: ActiveJob::Temporal.config.logger)
117
+ validate_event!(event_name)
118
+ attributes = normalize_attributes(attributes)
119
+
120
+ payload = build_payload(sanitize_log_value(event_name), sanitize_log_value(attributes))
121
+ return unless configured_logger.respond_to?(level)
122
+
123
+ if semantic_logger?(configured_logger)
124
+ configured_logger.public_send(level, payload)
125
+ else
126
+ configured_logger.public_send(level, JSON.generate(payload))
127
+ end
128
+ end
129
+
130
+ def sanitize_log_value(value)
131
+ case value
132
+ when String
133
+ escape_control_characters(value)
134
+ when Symbol
135
+ sanitize_symbol(value)
136
+ when Hash
137
+ value.each_with_object({}) do |(key, nested_value), result|
138
+ result[sanitize_log_key(key)] = sanitize_log_value(nested_value)
139
+ end
140
+ when Array
141
+ value.map { |element| sanitize_log_value(element) }
142
+ else
143
+ value
144
+ end
145
+ end
146
+
147
+ def sanitize_log_key(key)
148
+ case key
149
+ when String
150
+ escape_control_characters(key)
151
+ when Symbol
152
+ sanitize_symbol(key)
153
+ else
154
+ key
155
+ end
156
+ end
157
+
158
+ def sanitize_symbol(value)
159
+ sanitized = escape_control_characters(value.to_s)
160
+ sanitized == value.to_s ? value : sanitized
161
+ end
162
+
163
+ def escape_control_characters(value)
164
+ return value unless value.match?(CONTROL_CHARACTER_PATTERN)
165
+
166
+ value.gsub(CONTROL_CHARACTER_PATTERN) { |character| format("\\u%04X", character.ord) }
167
+ end
168
+
169
+ # Builds structured log payload with event and timestamp.
170
+ # @api private
171
+ def build_payload(event_name, attributes)
172
+ { event: event_name, timestamp: current_timestamp }.merge(attributes)
173
+ end
174
+
175
+ # Returns current UTC timestamp in ISO8601 format.
176
+ # @api private
177
+ def current_timestamp
178
+ Time.now.utc.iso8601
179
+ end
180
+
181
+ # Normalizes attributes to a hash.
182
+ # @api private
183
+ def normalize_attributes(attributes)
184
+ case attributes
185
+ when nil then {}
186
+ when Hash then attributes.dup
187
+ else
188
+ raise ArgumentError, "attributes must be a Hash"
189
+ end
190
+ end
191
+
192
+ # Validates event_name is a String or Symbol.
193
+ # @api private
194
+ def validate_event!(event_name)
195
+ return if event_name.is_a?(String) || event_name.is_a?(Symbol)
196
+
197
+ raise ArgumentError, "event_name must be a String or Symbol"
198
+ end
199
+
200
+ # Checks if the configured logger handles structured payloads itself.
201
+ # @api private
202
+ def semantic_logger?(configured_logger)
203
+ return false unless defined?(SemanticLogger)
204
+ return true if semantic_logger_instance?(configured_logger)
205
+
206
+ configured_logger.class.name.to_s.start_with?("SemanticLogger::")
207
+ end
208
+
209
+ def semantic_logger_instance?(configured_logger)
210
+ defined?(SemanticLogger::Logger) && configured_logger.is_a?(SemanticLogger::Logger)
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+ require "socket"
5
+
6
+ require_relative "connection_worker_pool"
7
+ require_relative "bind_policy"
8
+ require_relative "http_line_reader"
9
+
10
+ module ActiveJob
11
+ module Temporal
12
+ class MetricsServer
13
+ include HttpLineReader
14
+
15
+ DEFAULT_BIND_ADDRESS = "127.0.0.1"
16
+ READ_TIMEOUT_SECONDS = 1
17
+ CONTENT_TYPE = "text/plain; version=0.0.4"
18
+ CONNECTION_WORKERS = 4
19
+ CONNECTION_QUEUE_SIZE = 16
20
+
21
+ attr_reader :port, :bind_address
22
+
23
+ def initialize(port:, provider:, bind_address: DEFAULT_BIND_ADDRESS, allow_public_bind: false)
24
+ @requested_port = Integer(port)
25
+ @bind_address = bind_address
26
+ @allow_public_bind = allow_public_bind
27
+ @provider = provider
28
+ @running = false
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def start
33
+ @mutex.synchronize do
34
+ return self if @running
35
+
36
+ BindPolicy.validate!(
37
+ endpoint: "metrics",
38
+ bind_address: bind_address,
39
+ allow_public_bind: @allow_public_bind
40
+ )
41
+ @server = TCPServer.new(bind_address, @requested_port)
42
+ @port = @server.addr[1]
43
+ @connection_pool = ConnectionWorkerPool.new(
44
+ size: CONNECTION_WORKERS,
45
+ queue_size: CONNECTION_QUEUE_SIZE,
46
+ name: "activejob-temporal-metrics"
47
+ ) { |client| serve_client(client) }.start
48
+ @running = true
49
+ @thread = Thread.new { run }
50
+ end
51
+
52
+ self
53
+ end
54
+
55
+ def stop
56
+ server = nil
57
+ thread = nil
58
+ connection_pool = nil
59
+
60
+ @mutex.synchronize do
61
+ server = @server
62
+ thread = @thread
63
+ connection_pool = @connection_pool
64
+ @server = nil
65
+ @thread = nil
66
+ @connection_pool = nil
67
+ @running = false
68
+ end
69
+
70
+ server&.close
71
+ connection_pool&.stop(timeout: 2)
72
+ thread&.join(2)
73
+ end
74
+
75
+ def running?
76
+ @mutex.synchronize { @running }
77
+ end
78
+
79
+ private
80
+
81
+ def run
82
+ loop do
83
+ server, connection_pool = @mutex.synchronize { [@server, @connection_pool] }
84
+ break unless server && connection_pool
85
+
86
+ connection_pool.enqueue(server.accept)
87
+ rescue IOError, Errno::EBADF
88
+ break
89
+ end
90
+ ensure
91
+ @mutex.synchronize { @running = false if @server }
92
+ end
93
+
94
+ def serve_client(client)
95
+ handle_client(client)
96
+ rescue IOError, SystemCallError
97
+ nil
98
+ ensure
99
+ client&.close
100
+ end
101
+
102
+ def handle_client(client)
103
+ request_line = read_line(client)
104
+ return unless request_line
105
+
106
+ method, path = request_line.split.first(2)
107
+ drain_headers(client)
108
+
109
+ unless method && path
110
+ write_text(client, 400, "bad_request\n")
111
+ return
112
+ end
113
+
114
+ case [method, path]
115
+ in ["GET" | "HEAD", "/metrics"]
116
+ write_text(client, 200, @provider.render, body: method == "GET")
117
+ in [_, "/metrics"]
118
+ write_text(client, 405, "method_not_allowed\n")
119
+ else
120
+ write_text(client, 404, "not_found\n")
121
+ end
122
+ end
123
+
124
+ def drain_headers(client)
125
+ loop do
126
+ line = read_line(client)
127
+ break if line.nil? || line == "\r\n" || line == "\n"
128
+ end
129
+ end
130
+
131
+ def write_text(client, status, payload, body: true)
132
+ response = "HTTP/1.1 #{status} #{reason_phrase(status)}\r\n"
133
+ response << "Content-Type: #{CONTENT_TYPE}\r\n"
134
+ response << "Content-Length: #{body ? payload.bytesize : 0}\r\n"
135
+ response << "Connection: close\r\n\r\n"
136
+ response << payload if body
137
+ client.write(response)
138
+ end
139
+
140
+ def reason_phrase(status)
141
+ {
142
+ 200 => "OK",
143
+ 400 => "Bad Request",
144
+ 404 => "Not Found",
145
+ 405 => "Method Not Allowed"
146
+ }.fetch(status)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module Middleware
6
+ # Ordered middleware pipeline for ActiveJob execution inside Temporal activities.
7
+ class Chain
8
+ include Enumerable
9
+
10
+ TERMINAL_CALL_CHAIN = ->(_job, terminal) { terminal.call }.freeze
11
+
12
+ def initialize(entries = [])
13
+ @entries = []
14
+ @entry_indexes_by_key = {}
15
+ @compiled_call_chain = TERMINAL_CALL_CHAIN
16
+ entries.each { |entry| add(entry) }
17
+ end
18
+
19
+ def add(middleware, *args, **kwargs, &block)
20
+ key = entry_key(middleware, args, kwargs, block)
21
+ callable = build_callable(middleware, args, kwargs, block)
22
+ upsert_entry(key, callable)
23
+ @compiled_call_chain = compile_call_chain
24
+
25
+ callable
26
+ end
27
+
28
+ def call(job, &terminal)
29
+ raise ArgumentError, "middleware chain requires a block" unless terminal
30
+
31
+ @compiled_call_chain.call(job, terminal)
32
+ end
33
+
34
+ def each(&block)
35
+ return enum_for(:each) unless block
36
+
37
+ @entries.each(&block)
38
+ end
39
+
40
+ private
41
+
42
+ def upsert_entry(key, callable)
43
+ if @entry_indexes_by_key.key?(key)
44
+ @entries[@entry_indexes_by_key.fetch(key)] = callable
45
+ else
46
+ @entry_indexes_by_key[key] = @entries.length
47
+ @entries << callable
48
+ end
49
+ end
50
+
51
+ def entry_key(middleware, args, kwargs, block)
52
+ [
53
+ middleware_key(middleware),
54
+ args.map { |argument| argument_key(argument) },
55
+ kwargs.sort_by { |key, _value| key.to_s }.map { |key, value| [key, argument_key(value)] },
56
+ block_key(block)
57
+ ]
58
+ end
59
+
60
+ def middleware_key(middleware)
61
+ return [:class, middleware.name] if middleware.is_a?(Class) && middleware.name
62
+ return [:callable_source, middleware.source_location] if middleware.respond_to?(:source_location)
63
+
64
+ [:object, middleware.object_id]
65
+ end
66
+
67
+ def argument_key(argument)
68
+ case argument
69
+ when NilClass, TrueClass, FalseClass, Numeric, Symbol
70
+ argument
71
+ when String
72
+ argument.dup.freeze
73
+ else
74
+ [:object, argument.object_id]
75
+ end
76
+ end
77
+
78
+ def block_key(block)
79
+ block&.source_location || block&.object_id
80
+ end
81
+
82
+ def compile_call_chain
83
+ @entries.reverse_each.reduce(TERMINAL_CALL_CHAIN) do |next_middleware, middleware|
84
+ lambda do |job, terminal|
85
+ middleware.call(job) { next_middleware.call(job, terminal) }
86
+ end
87
+ end
88
+ end
89
+
90
+ def build_callable(middleware, args, kwargs, block)
91
+ callable = if middleware.is_a?(Class)
92
+ middleware.new(*args, **kwargs, &block)
93
+ elsif args.empty? && kwargs.empty? && block.nil?
94
+ middleware
95
+ else
96
+ raise ArgumentError, "middleware arguments require a middleware class"
97
+ end
98
+
99
+ return callable if callable.respond_to?(:call)
100
+
101
+ raise ArgumentError, "middleware must respond to #call"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "middleware/chain"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ # Runtime extension points for activity execution.
8
+ module Middleware
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../observability"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module Observability
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Datadog < Adapter
10
+ attr_accessor :service, :statsd_host, :statsd_port
11
+ attr_writer :statsd
12
+
13
+ def initialize(service: "activejob-temporal", statsd: nil, statsd_host: "127.0.0.1", statsd_port: 8125)
14
+ super(:datadog)
15
+ @service = service
16
+ @statsd = statsd
17
+ @statsd_host = statsd_host
18
+ @statsd_port = statsd_port
19
+ end
20
+
21
+ def trace_context_for_enqueue(_payload)
22
+ trace = ::Datadog::Tracing.active_trace if defined?(::Datadog::Tracing)
23
+ return {} unless trace.respond_to?(:to_digest)
24
+
25
+ carrier = {}
26
+ ::Datadog::Tracing::Contrib::HTTP.inject(trace.to_digest, carrier)
27
+ carrier
28
+ end
29
+
30
+ def record(event_name, payload)
31
+ case event_name
32
+ when :enqueue then record_enqueue(payload)
33
+ when :payload_serialize then record_payload_size(payload)
34
+ when :retry then record_retry(payload)
35
+ when :worker_start then record_worker_started(payload)
36
+ when :worker_stop then record_worker_stopped(payload)
37
+ when :active_tasks then record_active_tasks(payload)
38
+ end
39
+ end
40
+
41
+ def instrument(event_name, payload)
42
+ return yield unless event_name == :perform
43
+
44
+ started_at = monotonic_time
45
+ trace(event_name, payload) do
46
+ result = yield
47
+ statsd.increment("activejob_temporal.jobs.completed", tags: tags(payload))
48
+ result
49
+ rescue StandardError => e
50
+ statsd.increment("activejob_temporal.jobs.failed", tags: tags(payload, error: e.class.name))
51
+ raise
52
+ ensure
53
+ statsd.histogram(
54
+ "activejob_temporal.job_duration.seconds",
55
+ monotonic_time - started_at,
56
+ tags: tags(payload)
57
+ )
58
+ end
59
+ end
60
+
61
+ def validate_dependencies!
62
+ require_dependency("datadog", "datadog", "Datadog")
63
+ require_dependency("datadog", "datadog/statsd", "Datadog")
64
+ require_dependency("datadog", "datadog/tracing/contrib/http", "Datadog")
65
+ self
66
+ end
67
+
68
+ private
69
+
70
+ def record_enqueue(payload)
71
+ trace(:enqueue, payload) do
72
+ statsd.increment("activejob_temporal.jobs.enqueued", tags: tags(payload))
73
+ end
74
+ end
75
+
76
+ def record_payload_size(payload)
77
+ statsd.histogram("activejob_temporal.payload_size.bytes", payload[:bytes], tags: tags(payload))
78
+ end
79
+
80
+ def record_retry(payload)
81
+ statsd.increment("activejob_temporal.retries", tags: tags(payload, error: payload[:error]))
82
+ end
83
+
84
+ def record_worker_started(payload)
85
+ statsd.gauge("activejob_temporal.active_workers", 1, tags: worker_tags(payload))
86
+ end
87
+
88
+ def record_worker_stopped(payload)
89
+ statsd.gauge("activejob_temporal.active_workers", 0, tags: worker_tags(payload))
90
+ statsd.gauge("activejob_temporal.active_tasks", 0, tags: worker_tags(payload))
91
+ end
92
+
93
+ def record_active_tasks(payload)
94
+ statsd.gauge("activejob_temporal.active_tasks", payload[:count].to_i, tags: worker_tags(payload))
95
+ end
96
+
97
+ def trace(event_name, payload)
98
+ digest = extracted_digest(payload)
99
+ options = {
100
+ service: service,
101
+ resource: payload[:job_class]
102
+ }.compact
103
+ options[:continue_from] = digest if digest
104
+
105
+ ::Datadog::Tracing.trace("activejob_temporal.#{event_name}", **options) do |span|
106
+ set_span_tags(span, payload)
107
+ yield
108
+ end
109
+ end
110
+
111
+ def extracted_digest(payload)
112
+ carrier = Observability.trace_context_from_payload(payload).fetch("datadog", nil)
113
+ return if carrier.nil? || carrier.empty?
114
+
115
+ ::Datadog::Tracing::Contrib::HTTP.extract(carrier)
116
+ end
117
+
118
+ def set_span_tags(span, payload)
119
+ span.set_tag("activejob_temporal.job_class", payload[:job_class])
120
+ span.set_tag("activejob_temporal.job_id", payload[:job_id])
121
+ span.set_tag("activejob_temporal.queue", payload[:queue])
122
+ span.set_tag("activejob_temporal.workflow_id", payload[:workflow_id])
123
+ span.set_tag("activejob_temporal.run_id", payload[:run_id])
124
+ span.set_tag("activejob_temporal.namespace", payload[:namespace])
125
+ span.set_tag("activejob_temporal.task_queue", payload[:task_queue])
126
+ span.set_tag("activejob_temporal.attempt", payload[:attempt])
127
+ end
128
+
129
+ def statsd
130
+ @statsd ||= ::Datadog::Statsd.new(statsd_host, statsd_port)
131
+ end
132
+
133
+ def tags(payload, error: nil)
134
+ [
135
+ tag("job_class", payload[:job_class]),
136
+ tag("queue", payload[:queue]),
137
+ tag("namespace", payload[:namespace]),
138
+ tag("task_queue", payload[:task_queue]),
139
+ tag("error", error)
140
+ ].compact
141
+ end
142
+
143
+ def worker_tags(payload)
144
+ [
145
+ tag("namespace", payload[:namespace]),
146
+ tag("task_queue", payload[:task_queue]),
147
+ tag("worker_id", payload[:worker_id])
148
+ ].compact
149
+ end
150
+
151
+ def tag(name, value)
152
+ "#{name}:#{value}" unless value.nil?
153
+ end
154
+
155
+ def monotonic_time
156
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
157
+ end
158
+ end
159
+ # rubocop:enable Metrics/ClassLength
160
+ end
161
+ end
162
+ end
163
+
164
+ ActiveJob::Temporal::Observability.register_adapter(
165
+ :datadog,
166
+ ActiveJob::Temporal::Observability::Datadog
167
+ )