nexus_semantic_logger 1.14.2 → 1.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f3b89f4f80f8f27b69fa3acdf26a8ee5bedd3879fdcae28e18c43c9a6150b55
4
- data.tar.gz: 4778cc57aa7c007c324d53b665f7bea976fc9ac8ab31767e69d96df4d36eee37
3
+ metadata.gz: 0dcbcb0a7a4c7de68e7496b8ffb7f6d5d5e08742a981af559e48162bd149fc40
4
+ data.tar.gz: 4cd8249c19ce91fc5a3f2ef7c1919f91685235cf385291f8e0272ecfaf71234f
5
5
  SHA512:
6
- metadata.gz: da93370c79b39887353e2397ee5c9919d554f9e9a51d3c66673020f75e15d437231b807bfcfd60662751f87e77888427eb51a78b8df9253fdc40b191ac37aa9e
7
- data.tar.gz: 6bcc8e29ca2f7907c3d27ec4941bc98e86b03948fbc665bc640505e528317b82d96e9501765e9d915f943b3d9b48d8a565f791e11eb52f1e2c3d00cfd7cd1211
6
+ metadata.gz: 17a71300ae2d6f1bd8c5d0175b167071b818c54ea489d5973a6a2d92cc2fa99dd8f8369015b57520ae8eaf2dfef84f17b30e28c12736637c1620d48ec435d28c
7
+ data.tar.gz: f93a642bc2e30c4913f65cab35cbda108a99d9b15f78fab5f715aa91d53511560d9a2189bd10d02782a90c9b810add8efec1509278bb6108fd4a1ee0b9258416
data/README.md CHANGED
@@ -60,6 +60,20 @@ For example, to increment a count:
60
60
  NexusSemanticLogger.metrics.increment('nexus.users.registration.complete')
61
61
  ```
62
62
 
63
+ ### Running shell commands
64
+
65
+ Use `NexusSemanticLogger.shell.capture3` as a drop-in replacement for `Open3.capture3`:
66
+
67
+ ```
68
+ stdout, stderr, status = NexusSemanticLogger.shell.capture3('7z', 'x', archive_path)
69
+ ```
70
+
71
+ Each call is wrapped in a Datadog APM span (`shell.exec`) and emits `nexus.shell.duration_ms`,
72
+ `nexus.shell.peak_rss_mb` (the child's peak RSS, sampled from procfs) and `nexus.shell.executions`
73
+ metrics tagged with `command:<binary>` and `outcome:<success|failure|signaled|exception>`, plus an
74
+ info log line. A child killed by the kernel OOM killer shows up as `outcome:signaled` with
75
+ `termsig: 9` and its last-observed peak RSS.
76
+
63
77
  # Local gem development
64
78
 
65
79
  Steps to run this gem from local sources in one the nexus 'staged build' rails components:
@@ -22,7 +22,6 @@ module NexusSemanticLogger
22
22
  hash[:source] = :rails
23
23
  level = hash.delete(:level)
24
24
  hash[:status] = level
25
- hash[:railsenv] = Rails.env
26
25
  hash[:service] = @service
27
26
  hash.delete(:application)
28
27
  hash.delete(:environment)
@@ -18,7 +18,6 @@ module NexusSemanticLogger
18
18
  # Note that 'env' is NOT sent- that is set as the default on the agent e.g. staging, canary, production.
19
19
  # It does not necessarily align with the Rails env, and we do not want to double tag the env.
20
20
  global_tags = [
21
- "railsenv:#{Rails.env}",
22
21
  "service:#{service}",
23
22
  "container_name:#{container_name}",
24
23
  "pod_name:#{pod_name}",
@@ -42,7 +41,6 @@ module NexusSemanticLogger
42
41
  # Trace tags API is Hash<String,String>, see https://www.rubydoc.info/gems/ddtrace/Datadog/Tracing
43
42
  # Should match the global tags, but as a Hash.
44
43
  c.tags = {
45
- railsenv: Rails.env,
46
44
  service: service,
47
45
  container_name: container_name,
48
46
  pod_name: pod_name,
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+ require 'open3'
3
+ require 'semantic_logger'
4
+
5
+ module NexusSemanticLogger
6
+ # Instrumented replacement for Open3.capture3, so child processes (7z, unrar, yara, ...) are
7
+ # observable. Each call produces:
8
+ # - a Datadog APM span ('shell.exec', resource = binary basename) tagged with the command line,
9
+ # exit status, terminating signal and peak RSS
10
+ # - statsd metrics: nexus.shell.duration_ms (timing), nexus.shell.peak_rss_mb (distribution)
11
+ # and nexus.shell.executions (count), tagged with command:<binary> and
12
+ # outcome:<success|failure|signaled|exception>
13
+ # - an info log line with the same payload
14
+ #
15
+ # Peak RSS is the child's kernel-maintained high-water mark (VmHWM in /proc/<pid>/status),
16
+ # sampled while the child runs. Linux only; nil elsewhere. Direct child only - grandchildren
17
+ # are not measured. A process killed by the kernel OOM killer reports outcome:signaled with
18
+ # termsig 9 and the last-sampled peak RSS, which is exactly the evidence an OOM postmortem needs.
19
+ class TracedShell
20
+ include SemanticLogger::Loggable
21
+
22
+ SPAN_NAME = 'shell.exec'
23
+ METRIC_PREFIX = 'nexus.shell'
24
+ RSS_POLL_INTERVAL_SECONDS = 0.05
25
+ COMMAND_LOG_MAX_CHARS = 500
26
+
27
+ # Drop-in replacement for Open3.capture3.
28
+ # A leading env Hash and trailing spawn options are supported as with Open3; the env Hash is
29
+ # excluded from spans/logs since it may hold secrets.
30
+ # @return [Array(String, String, Process::Status)] stdout, stderr, status.
31
+ def capture3(*cmd, stdin_data: '', binmode: false, **opts)
32
+ binary = command_basename(cmd)
33
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ outcome = 'exception'
35
+ status = nil
36
+ peak_rss_kb = nil
37
+ trace(binary, cmd) do |span|
38
+ stdout, stderr, status, peak_rss_kb = run_capture3(cmd, stdin_data, binmode, opts)
39
+ outcome = outcome_for(status)
40
+ annotate_span(span, status, peak_rss_kb)
41
+ [stdout, stderr, status]
42
+ end
43
+ ensure
44
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
45
+ record(cmd, outcome, duration_ms, status, peak_rss_kb)
46
+ end
47
+
48
+ private
49
+
50
+ def trace(binary, cmd, &block)
51
+ return yield(nil) unless defined?(Datadog::Tracing)
52
+
53
+ Datadog::Tracing.trace(SPAN_NAME, resource: binary, type: 'system') do |span|
54
+ span.set_tag('shell.command', command_string(cmd))
55
+ block.call(span)
56
+ end
57
+ end
58
+
59
+ # Open3.capture3 semantics, reimplemented over popen3 so the child pid is available to the
60
+ # RSS poller while it runs.
61
+ def run_capture3(cmd, stdin_data, binmode, opts)
62
+ Open3.popen3(*cmd, **opts) do |stdin, stdout, stderr, wait_thr|
63
+ if binmode
64
+ stdin.binmode
65
+ stdout.binmode
66
+ stderr.binmode
67
+ end
68
+ poller = start_rss_poller(wait_thr)
69
+ out_reader = Thread.new { stdout.read }
70
+ err_reader = Thread.new { stderr.read }
71
+ begin
72
+ stdin.write(stdin_data) if stdin_data && !stdin_data.empty?
73
+ rescue Errno::EPIPE
74
+ # Child exited without consuming stdin; Open3.capture3 ignores this too.
75
+ end
76
+ stdin.close
77
+ status = wait_thr.value
78
+ [out_reader.value, err_reader.value, status, poller&.value]
79
+ end
80
+ end
81
+
82
+ def start_rss_poller(wait_thr)
83
+ return nil unless procfs?
84
+
85
+ pid = wait_thr.pid
86
+ Thread.new do
87
+ peak_kb = nil
88
+ while wait_thr.alive?
89
+ sample = read_vm_hwm_kb(pid)
90
+ peak_kb = sample if sample && (peak_kb.nil? || sample > peak_kb)
91
+ sleep(RSS_POLL_INTERVAL_SECONDS)
92
+ end
93
+ peak_kb
94
+ end
95
+ end
96
+
97
+ # VmHWM is the process's peak RSS as maintained by the kernel, so each sample is itself a
98
+ # high-water mark; the true peak can only be missed by the process exiting within one poll.
99
+ def read_vm_hwm_kb(pid)
100
+ File.read("/proc/#{pid}/status")[/^VmHWM:\s*(\d+)\s*kB/, 1]&.to_i
101
+ rescue SystemCallError, IOError
102
+ nil
103
+ end
104
+
105
+ def procfs?
106
+ return @procfs if defined?(@procfs)
107
+
108
+ @procfs = File.exist?('/proc/self/status')
109
+ end
110
+
111
+ def outcome_for(status)
112
+ return 'success' if status.success?
113
+ return 'signaled' if status.signaled?
114
+
115
+ 'failure'
116
+ end
117
+
118
+ def annotate_span(span, status, peak_rss_kb)
119
+ return unless span
120
+
121
+ span.set_tag('shell.exit_status', status.exitstatus) if status.exitstatus
122
+ span.set_tag('shell.termsig', status.termsig) if status.signaled?
123
+ span.set_tag('shell.peak_rss_mb', to_mb(peak_rss_kb)) if peak_rss_kb
124
+ end
125
+
126
+ def record(cmd, outcome, duration_ms, status, peak_rss_kb)
127
+ tags = ["command:#{command_basename(cmd)}", "outcome:#{outcome}"]
128
+ metrics = NexusSemanticLogger.metrics
129
+ metrics.timing("#{METRIC_PREFIX}.duration_ms", duration_ms, tags: tags)
130
+ metrics.increment("#{METRIC_PREFIX}.executions", tags: tags)
131
+ peak_rss_mb = peak_rss_kb && to_mb(peak_rss_kb)
132
+ metrics.distribution("#{METRIC_PREFIX}.peak_rss_mb", peak_rss_mb, tags: tags) if peak_rss_mb
133
+
134
+ payload = {
135
+ command: command_string(cmd),
136
+ outcome: outcome,
137
+ duration_ms: duration_ms,
138
+ exit_status: status&.exitstatus,
139
+ termsig: status&.signaled? ? status.termsig : nil,
140
+ peak_rss_mb: peak_rss_mb,
141
+ }.compact
142
+ logger.info('Shell command finished.', payload)
143
+ end
144
+
145
+ # Binary basename for span resource and metric tags; deliberately low cardinality.
146
+ def command_basename(cmd)
147
+ first = cmd.first.is_a?(Hash) ? cmd[1] : cmd.first
148
+ first = first.first if first.is_a?(Array) # [binary, argv0] form
149
+ return 'unknown' unless first.is_a?(String)
150
+
151
+ File.basename(first.split(' ').first.to_s)
152
+ end
153
+
154
+ def command_string(cmd)
155
+ parts = cmd.reject { |part| part.is_a?(Hash) }
156
+ parts.flatten.join(' ').slice(0, COMMAND_LOG_MAX_CHARS)
157
+ end
158
+
159
+ def to_mb(kb)
160
+ kb.fdiv(1024).round(1)
161
+ end
162
+ end
163
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusSemanticLogger
3
- # Leave this as 1.14.2 in order for CI process to replace with the tagged version.
4
- VERSION = '1.14.2'
3
+ # Leave this as 1.15.0 in order for CI process to replace with the tagged version.
4
+ VERSION = '1.15.0'
5
5
  end
@@ -5,12 +5,18 @@ require 'nexus_semantic_logger/datadog_formatter'
5
5
  require 'nexus_semantic_logger/datadog_singleton'
6
6
  require 'nexus_semantic_logger/datadog_tracer'
7
7
  require 'nexus_semantic_logger/logger_metrics_subscriber'
8
+ require 'nexus_semantic_logger/traced_shell'
8
9
 
9
10
  module NexusSemanticLogger
10
11
  # Get application wide object for sending metrics.
11
12
  def self.metrics
12
13
  DatadogSingleton.instance
13
14
  end
15
+
16
+ # Get application wide object for running instrumented shell commands.
17
+ def self.shell
18
+ @shell ||= TracedShell.new
19
+ end
14
20
  end
15
21
 
16
22
  # Patch access to LEVELS array.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nexus_semantic_logger
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.14.2
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johnathon Harris
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-04 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: amazing_print
@@ -133,6 +133,7 @@ files:
133
133
  - lib/nexus_semantic_logger/extensions/action_dispatch/debug_exceptions.rb
134
134
  - lib/nexus_semantic_logger/logger_metrics_subscriber.rb
135
135
  - lib/nexus_semantic_logger/sneakers_metrics.rb
136
+ - lib/nexus_semantic_logger/traced_shell.rb
136
137
  - lib/nexus_semantic_logger/version.rb
137
138
  - lib/puma/plugin/README.md
138
139
  - lib/puma/plugin/nexus_puma_statsd.rb