julewire-active_job 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a82446be20c0ec6d8ac39b204411837b4759cb57f94ddb9b2138173d16b5fc3d
4
+ data.tar.gz: 949f9196d2e54d90248b66bce79f34d20bd4be6fbae26667febe548527829cd1
5
+ SHA512:
6
+ metadata.gz: f0acd8d095c2b529f8bb865b3757bafa62a97e7997e5009d8a8960bd2b8c96e4ffbb2c3e7eb6345318f2150b0113f28e4236212571c0251eab74a89075dad694
7
+ data.tar.gz: fcf079813dfde620139088138f2705db70ad6a0ab62aa8f2655b977a3b9ccbcd04c27790d9582aabc6928f211fc2d182dfb488261d99d3e006fa5164571d70d1
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## Unreleased
2
+
3
+ ## 1.0.0 - 2026-06-21
4
+
5
+ - Initial release: ActiveJob execution summaries, structured events,
6
+ continuations, and propagation through job serialization.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alexander Grebennik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # Julewire Active Job
2
+
3
+ Active Job integration for Julewire.
4
+
5
+ It records job execution summaries, Active Job structured events, and Julewire
6
+ propagation carriers in serialized job data.
7
+
8
+ ## Quickstart
9
+
10
+ ```ruby
11
+ gem "julewire-active_job"
12
+ ```
13
+
14
+ In Rails, the Railtie installs the integration. Defaults are on:
15
+
16
+ ```ruby
17
+ config.julewire_active_job.execution = true
18
+ config.julewire_active_job.structured_events = true
19
+ config.julewire_active_job.propagation = true
20
+ ```
21
+
22
+ Outside Rails, install it after Active Job is loaded:
23
+
24
+ ```ruby
25
+ Julewire::ActiveJob.install!(base: ActiveJob::Base)
26
+ ```
27
+
28
+ Default behavior:
29
+
30
+ - job execution scopes emit `job.completed` summaries
31
+ - Active Job structured events become point records
32
+ - carriers restore upstream Julewire context before `perform`
33
+ - Active Job default text subscriber output is detached
34
+
35
+ Generic job metadata also appears in the record's `neutral` section as `job.*`
36
+ formatter-coordination fields. Full Active Job detail lives under
37
+ `attributes.active_job`. Propagated Julewire context stays separate and small.
38
+
39
+ ## Docs
40
+
41
+ - [Configuration](docs/configuration.md)
42
+ - [Advanced Configuration](docs/advanced-configuration.md)
43
+ - [Propagation](docs/propagation.md)
44
+ - [Continuations](docs/continuations.md)
45
+ - [Boundaries](docs/boundaries.md)
@@ -0,0 +1,12 @@
1
+ # Advanced Configuration
2
+
3
+ These options are stable knobs for unusual job serialization or event naming
4
+ needs.
5
+
6
+ | Option | Default | Purpose |
7
+ | --- | --- | --- |
8
+ | `carrier_key` | `Julewire::Core::Propagation::Carrier::DEFAULT_KEY` | Carrier key inside propagation envelopes. |
9
+ | `serialized_carrier_key` | `"julewire.carrier"` | Key used in serialized Active Job data. |
10
+ | `summary_event` | `"job.completed"` | Event name for job summaries. |
11
+ | `summary_severity` | `:info` | Severity for successful job summaries. |
12
+ | `event_prefixes` | `["active_job."]` | Structured event prefixes to emit. |
@@ -0,0 +1,13 @@
1
+ # Boundaries
2
+
3
+ This gem instruments Active Job.
4
+
5
+ It does not own:
6
+
7
+ - queue backend transport
8
+ - process supervisors
9
+ - queue-backend lifecycle hooks
10
+ - durable job delivery
11
+ - retry policy
12
+
13
+ Application logger calls made inside jobs remain normal Julewire records.
@@ -0,0 +1,27 @@
1
+ # Configuration
2
+
3
+ Rails apps configure this gem through `config.julewire_active_job`. Non-Rails
4
+ apps pass a `Configuration` instance to `install!`.
5
+
6
+ ## Default Path
7
+
8
+ | Option | Default | Purpose |
9
+ | --- | --- | --- |
10
+ | `enabled` | `true` | Install the integration. |
11
+ | `execution` | `true` | Wrap job perform calls in Julewire executions. |
12
+ | `structured_events` | `true` | Emit Active Job structured events. |
13
+ | `propagation` | `true` | Store and restore Julewire carriers in job data. |
14
+ | `source` | `"active_job"` | Source value on Active Job records. |
15
+
16
+ ## Common Knobs
17
+
18
+ | Option | Default | Purpose |
19
+ | --- | --- | --- |
20
+ | `carrier_max_bytes` | `nil` | Omit oversized carriers from serialized jobs. |
21
+ | `silence_log_subscriber` | `true` | Detach Active Job default text subscriber output. |
22
+
23
+ Example:
24
+
25
+ ```ruby
26
+ config.julewire_active_job.carrier_max_bytes = 16_384
27
+ ```
@@ -0,0 +1,21 @@
1
+ # Continuations
2
+
3
+ Rails continuation events run inside the Julewire job execution scope when the
4
+ execution callback is installed.
5
+
6
+ The job summary records these values under `attributes.active_job`:
7
+
8
+ - `continuation_steps_started`
9
+ - `continuation_steps_completed`
10
+ - `continuation_steps_interrupted`
11
+ - `continuation_steps_failed`
12
+ - `continuation_steps_skipped`
13
+ - `continuation_interruptions`
14
+ - `continuation_resumptions`
15
+ - `continuation_description`
16
+ - `continuation_interrupt_reason`
17
+ - `continuation_last_step`
18
+ - `continuation_last_step_cursor`
19
+ - `continuation_last_step_resumed`
20
+ - `continuation_last_step_state`
21
+ - `continuation_status`
@@ -0,0 +1,17 @@
1
+ # Propagation
2
+
3
+ When a job is serialized, the gem stores a Julewire carrier under
4
+ `julewire.carrier`. When the job performs, the carrier is restored before the
5
+ job execution scope starts.
6
+
7
+ That lets upstream context flow into the job without emitting propagation-only
8
+ data by default.
9
+
10
+ Set `carrier_max_bytes` to omit oversized carriers from serialized job payloads.
11
+ When omitted, the job still runs normally; it starts without upstream Julewire
12
+ context.
13
+
14
+ Generic job metadata such as class, id, queue, priority, execution count,
15
+ timestamps, and status is emitted in the record's `neutral` section as `job.*`
16
+ formatter-coordination fields. Full Active Job metadata, including framework-
17
+ specific status and exception fields, is emitted under `attributes.active_job`.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/julewire/active_job/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "julewire-active_job"
7
+ spec.version = Julewire::ActiveJob::VERSION
8
+ spec.authors = ["Alexander Grebennik"]
9
+ spec.email = ["slbug@users.noreply.github.com", "sl.bug.sl@gmail.com"]
10
+
11
+ spec.summary = "Active Job integration for Julewire structured logging."
12
+ spec.description =
13
+ "Execution-scoped Active Job instrumentation, structured event capture, " \
14
+ "and propagation carrier support for Julewire."
15
+ spec.homepage = "https://github.com/slbug/julewire"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.4"
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/slbug/julewire/tree/main/gems/active_job"
20
+ spec.metadata["changelog_uri"] = "https://github.com/slbug/julewire/blob/main/gems/active_job/CHANGELOG.md"
21
+
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ Dir[
26
+ "CHANGELOG.md",
27
+ "LICENSE.txt",
28
+ "README.md",
29
+ "docs/**/*.md",
30
+ "julewire-active_job.gemspec",
31
+ "lib/**/*.rb"
32
+ ]
33
+ end
34
+ spec.executables = []
35
+ spec.require_paths = ["lib"]
36
+
37
+ spec.add_dependency "activejob", ">= 8.1"
38
+ spec.add_dependency "julewire-core", ">= 1.0"
39
+ spec.add_dependency "julewire-rails_support", ">= 1.0"
40
+ spec.add_dependency "zeitwerk", ">= 2.8.1"
41
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module ActiveJob
5
+ class Configuration
6
+ DEFAULT_SERIALIZED_CARRIER_KEY = "julewire.carrier"
7
+
8
+ include Julewire::Core::Integration::Settings
9
+
10
+ setting :enabled, default: true, predicate: true
11
+ setting :execution, default: true, predicate: true
12
+ setting :structured_events, default: true, predicate: true
13
+ setting :silence_log_subscriber, default: true, predicate: true
14
+ setting :propagation, default: true, predicate: true
15
+ setting :carrier_key, default: Julewire::Core::Propagation::Carrier::DEFAULT_KEY
16
+ setting :carrier_max_bytes, validate: byte_limit
17
+ setting :serialized_carrier_key, default: DEFAULT_SERIALIZED_CARRIER_KEY
18
+ setting :source, default: "active_job"
19
+ setting :summary_event, default: "job.completed"
20
+ setting :summary_severity, default: :info
21
+ setting :event_prefixes, default: ["active_job."]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module ActiveJob
5
+ module Installer
6
+ EXECUTION_INSTALL = Core::Integration::IvarState.new(:@julewire_active_job_execution)
7
+
8
+ class << self
9
+ def install!(base: nil, event_reporter: nil, configuration: Configuration.new)
10
+ return unless configuration.enabled?
11
+
12
+ Julewire::ActiveJob.config = configuration
13
+ base ||= active_job_base
14
+ raise Error, "ActiveJob::Base is not available" unless base
15
+
16
+ install_serialization(base, configuration)
17
+ install_execution_callback(base, configuration)
18
+ if configuration.structured_events?
19
+ Subscribers::Event.install!(configuration, event_reporter: event_reporter)
20
+ else
21
+ Subscribers::Event.reset!
22
+ end
23
+ LogSubscriberSilencer.silence! if configuration.silence_log_subscriber?
24
+ base
25
+ end
26
+
27
+ private
28
+
29
+ def active_job_base
30
+ require "active_job/base"
31
+ ::ActiveJob::Base
32
+ end
33
+
34
+ def install_serialization(base, configuration)
35
+ install_serialization_configuration(base, configuration)
36
+ return if base < JobSerialization
37
+
38
+ base.prepend(JobSerialization)
39
+ end
40
+
41
+ def install_serialization_configuration(base, configuration)
42
+ if base.respond_to?(:class_attribute)
43
+ unless base.respond_to?(JobSerialization::CONFIGURATION_METHOD)
44
+ base.class_attribute(
45
+ JobSerialization::CONFIGURATION_METHOD,
46
+ instance_accessor: false,
47
+ instance_predicate: false
48
+ )
49
+ end
50
+ base.public_send("#{JobSerialization::CONFIGURATION_METHOD}=", configuration)
51
+ else
52
+ base.instance_variable_set(JobSerialization::CONFIGURATION_IVAR, configuration)
53
+ end
54
+ end
55
+
56
+ def install_execution_callback(base, configuration)
57
+ return unless configuration.execution?
58
+
59
+ installed = EXECUTION_INSTALL.fetch(base)
60
+ if installed
61
+ installed.configuration = configuration
62
+ return installed
63
+ end
64
+
65
+ callback = ExecutionCallback.new(configuration)
66
+ # Rails callbacks are easier to update in place than to remove safely.
67
+ base.around_perform do |job, block|
68
+ callback.call(job, &block)
69
+ end
70
+ EXECUTION_INSTALL.store(base, callback)
71
+ end
72
+ end
73
+
74
+ class ExecutionCallback
75
+ def initialize(configuration)
76
+ @configuration = configuration
77
+ end
78
+
79
+ attr_writer :configuration
80
+
81
+ def call(job, &)
82
+ Julewire::ActiveJob::JobExecution.call(job, configuration: @configuration, &)
83
+ end
84
+ end
85
+ private_constant :ExecutionCallback
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+
4
+ module Julewire
5
+ module ActiveJob
6
+ module JobAttributes
7
+ JOB_NAME_KEYS = %i[job_class class_name job_name].freeze
8
+ JOB_ID_KEYS = %i[job_id id].freeze
9
+ JOB_PROVIDER_ID_KEYS = %i[provider_job_id].freeze
10
+ JOB_QUEUE_NAME_KEYS = %i[queue queue_name].freeze
11
+ JOB_PRIORITY_KEYS = %i[priority].freeze
12
+ JOB_EXECUTION_COUNT_KEYS = %i[executions].freeze
13
+ JOB_ENQUEUED_AT_KEYS = %i[enqueued_at].freeze
14
+ JOB_SCHEDULED_AT_KEYS = %i[scheduled_at].freeze
15
+ JOB_STATUS_KEYS = %i[status].freeze
16
+ private_constant :JOB_NAME_KEYS
17
+ private_constant :JOB_ID_KEYS
18
+ private_constant :JOB_PROVIDER_ID_KEYS
19
+ private_constant :JOB_QUEUE_NAME_KEYS
20
+ private_constant :JOB_PRIORITY_KEYS
21
+ private_constant :JOB_EXECUTION_COUNT_KEYS
22
+ private_constant :JOB_ENQUEUED_AT_KEYS
23
+ private_constant :JOB_SCHEDULED_AT_KEYS
24
+ private_constant :JOB_STATUS_KEYS
25
+
26
+ class << self
27
+ def call(fields)
28
+ Core::Fields::AttributeKeys.fields(
29
+ Core::Fields::AttributeKeys::JOB_SYSTEM => "active_job",
30
+ Core::Fields::AttributeKeys::JOB_NAME => first_value(fields, keys: JOB_NAME_KEYS),
31
+ Core::Fields::AttributeKeys::JOB_ID => first_value(fields, keys: JOB_ID_KEYS),
32
+ Core::Fields::AttributeKeys::JOB_PROVIDER_ID => first_value(fields, keys: JOB_PROVIDER_ID_KEYS),
33
+ Core::Fields::AttributeKeys::JOB_QUEUE_NAME => first_value(fields, keys: JOB_QUEUE_NAME_KEYS),
34
+ Core::Fields::AttributeKeys::JOB_PRIORITY => first_value(fields, keys: JOB_PRIORITY_KEYS),
35
+ Core::Fields::AttributeKeys::JOB_EXECUTION_COUNT => first_value(fields, keys: JOB_EXECUTION_COUNT_KEYS),
36
+ Core::Fields::AttributeKeys::JOB_ENQUEUED_AT => first_value(fields, keys: JOB_ENQUEUED_AT_KEYS),
37
+ Core::Fields::AttributeKeys::JOB_SCHEDULED_AT => first_value(fields, keys: JOB_SCHEDULED_AT_KEYS),
38
+ Core::Fields::AttributeKeys::JOB_STATUS => first_value(fields, keys: JOB_STATUS_KEYS)
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def first_value(fields, keys:)
45
+ Core::Integration::Values::Read.first_value(fields, keys: keys)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Julewire
6
+ module ActiveJob
7
+ module JobExecution
8
+ class << self
9
+ def call(job, configuration: Configuration.new, &)
10
+ carrier = carrier_for(job)
11
+ return perform_job(job, configuration, &) unless configuration.propagation?
12
+
13
+ Julewire::Core::Propagation::Carrier.restore(carrier, key: configuration.carrier_key) do
14
+ perform_job(job, configuration, &)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def perform_job(job, configuration, &)
21
+ fields = job_fields(job)
22
+ Core::Integration::Facade.with_execution(**execution_options(job, configuration, fields)) do
23
+ install_context(fields)
24
+ perform_with_summary(&)
25
+ end
26
+ end
27
+
28
+ def carrier_for(job)
29
+ job.instance_variable_get(CARRIER_IVAR) || {}
30
+ rescue StandardError
31
+ {}
32
+ end
33
+
34
+ def execution_options(job, configuration, fields)
35
+ options = {
36
+ type: :job,
37
+ fields: { job_class: fields[:job_class] || job.class.name },
38
+ attributes: attributes_for(fields),
39
+ neutral: neutral_for(fields),
40
+ inherit_attributes: false,
41
+ summary_event: configuration.summary_event,
42
+ summary_severity: configuration.summary_severity,
43
+ summary_source: configuration.source
44
+ }
45
+ job_id = fields[:job_id]
46
+ options[:id] = job_id if job_id
47
+ options
48
+ end
49
+
50
+ def install_context(fields)
51
+ job_id = fields[:job_id]
52
+ Core::Integration::Facade.add_context(job_id: job_id) if job_id
53
+ end
54
+
55
+ def perform_with_summary
56
+ result = yield
57
+ add_summary(status: "ok")
58
+ result
59
+ rescue StandardError => e
60
+ add_summary(status: "error", exception_class: e.class.name)
61
+ raise
62
+ end
63
+
64
+ def add_summary(fields)
65
+ add_summary_neutral(fields)
66
+ Core::Integration::Facade.add_summary_attributes(completion_attributes(fields))
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
71
+ def job_fields(job)
72
+ values = Core::Integration::Values::Shape
73
+ fields = { job_class: job.class.name }
74
+ values.append_field(fields, :job_id, job_id(job))
75
+ values.append_field(fields, :provider_job_id, safe_call(job, :provider_job_id))
76
+ values.append_field(fields, :queue, safe_call(job, :queue_name))
77
+ values.append_field(fields, :priority, safe_call(job, :priority))
78
+ values.append_field(fields, :executions, safe_call(job, :executions))
79
+ values.append_field(
80
+ fields,
81
+ :enqueued_at,
82
+ values.timestamp(safe_call(job, :enqueued_at))
83
+ )
84
+ values.append_field(
85
+ fields,
86
+ :scheduled_at,
87
+ values.timestamp(safe_call(job, :scheduled_at))
88
+ )
89
+ fields
90
+ end
91
+
92
+ def job_id(job)
93
+ safe_call(job, :job_id)
94
+ end
95
+
96
+ def attributes_for(fields)
97
+ { active_job: fields }
98
+ end
99
+
100
+ def neutral_for(fields)
101
+ JobAttributes.call(fields)
102
+ end
103
+
104
+ def add_summary_neutral(fields)
105
+ Core::Integration::Facade.add_summary_neutral(JobAttributes.call(fields))
106
+ end
107
+
108
+ def completion_attributes(fields)
109
+ { active_job: fields }
110
+ end
111
+
112
+ def safe_call(object, method_name)
113
+ Core::Integration::Values::Read.value(object, method_name)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module ActiveJob
5
+ module JobSerialization
6
+ CONFIGURATION_IVAR = :@julewire_active_job_configuration
7
+ CONFIGURATION_METHOD = :julewire_active_job_configuration
8
+
9
+ def serialize
10
+ super.tap do |job_data|
11
+ inject_julewire_carrier(job_data)
12
+ end
13
+ end
14
+
15
+ def deserialize(job_data)
16
+ extract_julewire_carrier(job_data)
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ def inject_julewire_carrier(job_data)
23
+ configuration = julewire_active_job_configuration
24
+ return unless configuration.propagation?
25
+
26
+ carrier = Julewire::Core::Propagation::Carrier.inject(
27
+ {},
28
+ key: configuration.carrier_key,
29
+ max_bytes: configuration.carrier_max_bytes
30
+ )
31
+ return unless carrier
32
+
33
+ value = carrier[configuration.carrier_key.to_s]
34
+ job_data[configuration.serialized_carrier_key] = value if value
35
+ IntegrationHealth.record_success(action: :carrier_inject, component: :job_serialization)
36
+ rescue StandardError => e
37
+ IntegrationHealth.record_failure(e, action: :carrier_inject, component: :job_serialization)
38
+ end
39
+
40
+ def extract_julewire_carrier(job_data)
41
+ configuration = julewire_active_job_configuration
42
+ unless configuration.propagation?
43
+ instance_variable_set(CARRIER_IVAR, {})
44
+ return
45
+ end
46
+
47
+ value = job_data[configuration.serialized_carrier_key]
48
+ instance_variable_set(CARRIER_IVAR, value ? { configuration.carrier_key => value } : {})
49
+ IntegrationHealth.record_success(action: :carrier_extract, component: :job_serialization)
50
+ rescue StandardError => e
51
+ IntegrationHealth.record_failure(e, action: :carrier_extract, component: :job_serialization)
52
+ instance_variable_set(CARRIER_IVAR, {})
53
+ end
54
+
55
+ def julewire_active_job_configuration
56
+ if self.class.respond_to?(CONFIGURATION_METHOD)
57
+ configuration = self.class.public_send(CONFIGURATION_METHOD)
58
+ return configuration if configuration
59
+ end
60
+
61
+ self.class.ancestors.each do |ancestor|
62
+ next unless ancestor.instance_variable_defined?(CONFIGURATION_IVAR)
63
+
64
+ return ancestor.instance_variable_get(CONFIGURATION_IVAR)
65
+ end
66
+ Julewire::ActiveJob.config
67
+ rescue StandardError
68
+ Julewire::ActiveJob.config
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module ActiveJob
5
+ module LogSubscriberSilencer
6
+ class << self
7
+ def silence!
8
+ Core::Integration::Lifecycle.require_optional("active_job/log_subscriber")
9
+ subscriber_class = active_job_log_subscriber
10
+ return unless subscriber_class
11
+
12
+ subscriber_class.detach_from(:active_job) if subscriber_class.respond_to?(:detach_from)
13
+ Julewire::RailsSupport::EventReporter.unsubscribe_log_subscriber(subscriber_class)
14
+ end
15
+
16
+ private
17
+
18
+ def active_job_log_subscriber
19
+ ::ActiveJob::LogSubscriber if defined?(::ActiveJob::LogSubscriber)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module ActiveJob
5
+ class Railtie < ::Rails::Railtie
6
+ config.julewire_active_job = Julewire::ActiveJob.config
7
+
8
+ initializer "julewire.active_job" do |app|
9
+ Julewire::ActiveJob::Railtie.install_active_job!(app.config.julewire_active_job)
10
+ end
11
+
12
+ class << self
13
+ def install_active_job!(settings)
14
+ return unless settings.enabled?
15
+
16
+ ActiveSupport.on_load(:active_job) do
17
+ Julewire::ActiveJob.install!(base: self, configuration: settings)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module ActiveJob
5
+ module Subscribers
6
+ class Event
7
+ include Core::Integration::EventSubscriber
8
+
9
+ STRUCTURED_EVENT_FILE = "active_job/structured_event_subscriber"
10
+ ERROR_EVENTS = %w[
11
+ active_job.retry_stopped
12
+ active_job.discarded
13
+ ].freeze
14
+
15
+ event_subscriber integration_health: IntegrationHealth, configuration_class: Configuration
16
+
17
+ class << self
18
+ include Core::Integration::SubscriberInstall
19
+
20
+ def install!(configuration, event_reporter: nil)
21
+ return reset! unless configuration.structured_events?
22
+
23
+ Core::Integration::Lifecycle.require_optional(STRUCTURED_EVENT_FILE)
24
+ reporter = event_reporter || Julewire::RailsSupport::EventReporter.default
25
+ return unless Julewire::RailsSupport::EventReporter.subscribable?(reporter)
26
+
27
+ install_subscriber(configuration, enabled: true) do |subscriber|
28
+ Julewire::RailsSupport::EventReporter.subscribe(reporter, subscriber) { subscriber.accept?(it) }
29
+ end
30
+ end
31
+ end
32
+
33
+ def accept?(event)
34
+ return false unless @configuration.structured_events?
35
+
36
+ prefixes = @configuration.event_prefixes
37
+ return true if prefixes.nil?
38
+
39
+ name = event[:name].to_s
40
+ Array(prefixes).any? { name.start_with?(it.to_s) }
41
+ end
42
+
43
+ private
44
+
45
+ def emit_event(event)
46
+ name = event[:name].to_s
47
+ record = record_for(event, name)
48
+ enrich_continuation_summary(name, record.dig(:attributes, :active_job) || {})
49
+ Core::Integration::Facade.emit(record)
50
+ end
51
+
52
+ def record_for(event, name)
53
+ values = Core::Integration::Values::Shape
54
+ payload = values.payload_hash(event[:payload])
55
+ record = base_record(event, name, payload)
56
+ record[:error] = error_payload(payload) if exception_payload?(payload)
57
+ record
58
+ end
59
+
60
+ def base_record(event, name, payload)
61
+ values = Core::Integration::Values::Shape
62
+ record = {
63
+ severity: severity_for(name, payload),
64
+ event: name,
65
+ logger: "ActiveJob.event",
66
+ context: values.hash_or_empty(event[:context]),
67
+ attributes: attributes_for(event, payload),
68
+ neutral: neutral_for(event, payload)
69
+ }
70
+ values.append_field(record, :timestamp, values.timestamp(event[:timestamp]))
71
+ values.append_field(record, :source, @configuration.source)
72
+ record
73
+ end
74
+
75
+ def attributes_for(event, payload)
76
+ values = Core::Integration::Values::Shape
77
+ active_job = payload.empty? ? {} : payload
78
+ values.append_compact_field(active_job, :tags, values.hash_or_empty(event[:tags]))
79
+ { active_job: active_job }
80
+ end
81
+
82
+ def neutral_for(event, payload)
83
+ values = Core::Integration::Values::Shape
84
+ Core::Fields::FieldSet.merge!(
85
+ JobAttributes.call(payload),
86
+ values.source_location_attributes(event[:source_location])
87
+ )
88
+ end
89
+
90
+ def severity_for(name, payload)
91
+ return :error if ERROR_EVENTS.include?(name)
92
+ return :error if exception_payload?(payload)
93
+
94
+ :info
95
+ end
96
+
97
+ def exception_payload?(payload)
98
+ return false unless payload.is_a?(Hash)
99
+
100
+ payload.key?(:exception_class) ||
101
+ payload.key?(:exception_message) ||
102
+ payload.key?(:exception_backtrace)
103
+ end
104
+
105
+ def error_payload(payload)
106
+ {
107
+ class: payload[:exception_class],
108
+ message: payload[:exception_message],
109
+ backtrace: payload[:exception_backtrace]
110
+ }.compact
111
+ end
112
+
113
+ def enrich_continuation_summary(name, payload)
114
+ return unless Core::Integration::Facade.summary_active?
115
+
116
+ case name
117
+ when "active_job.step_started"
118
+ increment_summary(:continuation_steps_started)
119
+ add_step_summary(payload, state: "started")
120
+ when "active_job.step"
121
+ enrich_completed_step_summary(payload)
122
+ when "active_job.step_skipped"
123
+ increment_summary(:continuation_steps_skipped)
124
+ add_step_summary(payload, state: "skipped")
125
+ when "active_job.interrupt"
126
+ enrich_interrupt_summary(payload)
127
+ when "active_job.resume"
128
+ enrich_resume_summary(payload)
129
+ end
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ def enrich_interrupt_summary(payload)
135
+ increment_summary(:continuation_interruptions)
136
+ Core::Integration::Facade.add_summary_attributes(
137
+ active_job: {
138
+ continuation_status: "interrupted",
139
+ continuation_description: payload[:description],
140
+ continuation_interrupt_reason: payload[:reason]
141
+ }
142
+ )
143
+ end
144
+
145
+ def enrich_resume_summary(payload)
146
+ increment_summary(:continuation_resumptions)
147
+ Core::Integration::Facade.add_summary_attributes(
148
+ active_job: {
149
+ continuation_status: "resumed",
150
+ continuation_description: payload[:description]
151
+ }
152
+ )
153
+ end
154
+
155
+ def enrich_completed_step_summary(payload)
156
+ if payload[:interrupted]
157
+ increment_summary(:continuation_steps_interrupted)
158
+ add_step_summary(payload, state: "interrupted")
159
+ elsif exception_payload?(payload)
160
+ increment_summary(:continuation_steps_failed)
161
+ add_step_summary(payload, state: "failed")
162
+ else
163
+ increment_summary(:continuation_steps_completed)
164
+ add_step_summary(payload, state: "completed")
165
+ end
166
+ end
167
+
168
+ def add_step_summary(payload, state:)
169
+ Core::Integration::Facade.add_summary_attributes(
170
+ active_job: {
171
+ continuation_last_step: payload[:step],
172
+ continuation_last_step_cursor: payload[:cursor],
173
+ continuation_last_step_state: state,
174
+ continuation_last_step_resumed: payload[:resumed]
175
+ }
176
+ )
177
+ end
178
+
179
+ def increment_summary(key)
180
+ Core::Integration::Facade.increment_summary_attribute(:active_job, key)
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module ActiveJob
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "julewire/core"
5
+ require "julewire/rails_support"
6
+
7
+ module Julewire
8
+ module ActiveJob
9
+ class Error < Julewire::Error; end
10
+ CARRIER_IVAR = :@julewire_carrier
11
+ IntegrationHealth = Core::Integration::Health.scoped(:active_job)
12
+ private_constant :CARRIER_IVAR
13
+
14
+ extend Core::Integration::Configurable
15
+
16
+ configurable_with { Configuration }
17
+
18
+ class << self
19
+ def install!(base: nil, event_reporter: nil, configuration: config)
20
+ Installer.install!(base: base, event_reporter: event_reporter, configuration: configuration)
21
+ end
22
+
23
+ def perform(job, &)
24
+ JobExecution.call(job, configuration: config, &)
25
+ end
26
+
27
+ def load_railtie_if_rails!
28
+ Railtie if defined?(::Rails::Railtie)
29
+ end
30
+ end
31
+ end
32
+
33
+ loader = Zeitwerk::Loader.for_gem_extension(self)
34
+ # Rails-only autoload, skipped when non-Rails processes eager load the gem.
35
+ loader.do_not_eager_load("#{__dir__}/active_job/railtie.rb")
36
+ loader.setup
37
+ Julewire::ActiveJob.load_railtie_if_rails!
38
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "julewire/active_job"
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: julewire-active_job
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexander Grebennik
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: julewire-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: julewire-rails_support
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 2.8.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 2.8.1
68
+ description: Execution-scoped Active Job instrumentation, structured event capture,
69
+ and propagation carrier support for Julewire.
70
+ email:
71
+ - slbug@users.noreply.github.com
72
+ - sl.bug.sl@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE.txt
79
+ - README.md
80
+ - docs/advanced-configuration.md
81
+ - docs/boundaries.md
82
+ - docs/configuration.md
83
+ - docs/continuations.md
84
+ - docs/propagation.md
85
+ - julewire-active_job.gemspec
86
+ - lib/julewire-active_job.rb
87
+ - lib/julewire/active_job.rb
88
+ - lib/julewire/active_job/configuration.rb
89
+ - lib/julewire/active_job/installer.rb
90
+ - lib/julewire/active_job/job_attributes.rb
91
+ - lib/julewire/active_job/job_execution.rb
92
+ - lib/julewire/active_job/job_serialization.rb
93
+ - lib/julewire/active_job/log_subscriber_silencer.rb
94
+ - lib/julewire/active_job/railtie.rb
95
+ - lib/julewire/active_job/subscribers/event.rb
96
+ - lib/julewire/active_job/version.rb
97
+ homepage: https://github.com/slbug/julewire
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ homepage_uri: https://github.com/slbug/julewire
102
+ source_code_uri: https://github.com/slbug/julewire/tree/main/gems/active_job
103
+ changelog_uri: https://github.com/slbug/julewire/blob/main/gems/active_job/CHANGELOG.md
104
+ rubygems_mfa_required: 'true'
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '3.4'
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubygems_version: 4.0.14
120
+ specification_version: 4
121
+ summary: Active Job integration for Julewire structured logging.
122
+ test_files: []