next_station 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.
- checksums.yaml +7 -0
- data/.aiignore +36 -0
- data/.idea/.gitignore +10 -0
- data/.idea/inspectionProfiles/Project_Default.xml +8 -0
- data/.idea/junie.xml +6 -0
- data/.idea/modules.xml +8 -0
- data/.idea/next_station.iml +54 -0
- data/.idea/vcs.xml +6 -0
- data/AGENTS.md +157 -0
- data/Gemfile +11 -0
- data/PLUGIN_SYSTEM_GUIDE.md +521 -0
- data/README.md +790 -0
- data/TODO.txt +6 -0
- data/examples/plugin_http_example.rb +102 -0
- data/lib/next_station/config/errors.yml +149 -0
- data/lib/next_station/config.rb +49 -0
- data/lib/next_station/environment.rb +42 -0
- data/lib/next_station/errors.rb +21 -0
- data/lib/next_station/logging/formatters/console.rb +38 -0
- data/lib/next_station/logging/formatters/json.rb +80 -0
- data/lib/next_station/logging/subscribers/base.rb +70 -0
- data/lib/next_station/logging/subscribers/custom.rb +25 -0
- data/lib/next_station/logging/subscribers/operation.rb +41 -0
- data/lib/next_station/logging/subscribers/step.rb +54 -0
- data/lib/next_station/logging.rb +35 -0
- data/lib/next_station/operation/class_methods.rb +299 -0
- data/lib/next_station/operation/errors.rb +97 -0
- data/lib/next_station/operation/node.rb +49 -0
- data/lib/next_station/operation.rb +393 -0
- data/lib/next_station/plugins.rb +23 -0
- data/lib/next_station/result.rb +124 -0
- data/lib/next_station/state.rb +64 -0
- data/lib/next_station/types.rb +11 -0
- data/lib/next_station/version.rb +5 -0
- data/lib/next_station.rb +36 -0
- metadata +203 -0
data/TODO.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
DONE - Force return of state inside the defs
|
|
2
|
+
DONE - Custom Error class for state not set: spec/contract/basic_flows/success_should_return_exception_if_no_success_key_spec.rb
|
|
3
|
+
DONE - install rubocop?
|
|
4
|
+
TODO - Raise exception if a step does not have a "def",
|
|
5
|
+
TODO: - Should "state[:result]" be created by default when ".new"?
|
|
6
|
+
DONE - " config.messages.backend = :i18n" In the validation section of the README is no valid dry-validation code.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../lib/next_station'
|
|
5
|
+
|
|
6
|
+
# --- The Plugin Definition ---
|
|
7
|
+
module HttpClientPlugin
|
|
8
|
+
module ClassMethods
|
|
9
|
+
def self.extended(base)
|
|
10
|
+
base.extend Dry::Configurable
|
|
11
|
+
base.instance_eval do
|
|
12
|
+
setting :http_client do
|
|
13
|
+
setting :base_url, default: "https://example.com"
|
|
14
|
+
setting :timeout, default: 5
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module InstanceMethods
|
|
21
|
+
def http_get(path)
|
|
22
|
+
uri = URI.parse(self.class.config.http_client.base_url)
|
|
23
|
+
uri = URI.join(uri, path)
|
|
24
|
+
|
|
25
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
26
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
27
|
+
http.read_timeout = self.class.config.http_client.timeout
|
|
28
|
+
|
|
29
|
+
request = Net::HTTP::Get.new(uri)
|
|
30
|
+
http.request(request)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
# In a real plugin, we would use error definitions,
|
|
33
|
+
# but for this example, we'll return a minimal object
|
|
34
|
+
Struct.new(:code, :body).new("500", e.message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module State
|
|
39
|
+
def response_received?
|
|
40
|
+
!self[:response].nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def last_response_code
|
|
44
|
+
self[:response]&.code
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Register the plugin manually for this standalone test
|
|
50
|
+
NextStation::Plugins.register(:http_client, HttpClientPlugin)
|
|
51
|
+
|
|
52
|
+
# --- The Operation Definition ---
|
|
53
|
+
class FetchExamplePage < NextStation::Operation
|
|
54
|
+
plugin :http_client
|
|
55
|
+
|
|
56
|
+
# Configure the plugin
|
|
57
|
+
config.http_client.base_url = "https://www.example.com"
|
|
58
|
+
config.http_client.timeout = 10
|
|
59
|
+
|
|
60
|
+
# Tell NextStation where to find the result in the state
|
|
61
|
+
result_at :content_length
|
|
62
|
+
|
|
63
|
+
process do
|
|
64
|
+
step :call_api
|
|
65
|
+
step :process_response
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call_api(state)
|
|
69
|
+
publish_log :info, "Calling API with: #{self.class.config.http_client.base_url}"
|
|
70
|
+
response = http_get("/")
|
|
71
|
+
state[:response] = response
|
|
72
|
+
state
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def process_response(state)
|
|
76
|
+
# Using the State extension defined in the plugin
|
|
77
|
+
if state.response_received? && state.last_response_code == "200"
|
|
78
|
+
publish_log :info, "Success! Response code: #{state.last_response_code}"
|
|
79
|
+
publish_log :info, "Body length: #{state[:response].body.length}"
|
|
80
|
+
# In NextStation, you just return the state (or a hash that will be merged into state)
|
|
81
|
+
# To signal success with data, the operation's result_key must match what we return or what's in state.
|
|
82
|
+
state[:content_length] = state[:response].body.length
|
|
83
|
+
state
|
|
84
|
+
else
|
|
85
|
+
puts "Failed! Response code: #{state.last_response_code || 'N/A'}"
|
|
86
|
+
# If we want to fail explicitly, we can use error! or just return something that isn't state/success
|
|
87
|
+
error!(type: :api_error, details: { message: "Response was #{state.last_response_code}" })
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- Execution ---
|
|
93
|
+
puts "Starting Operation..."
|
|
94
|
+
result = FetchExamplePage.call
|
|
95
|
+
|
|
96
|
+
puts "\nFinal Result: #{result.success? ? 'SUCCESS' : 'FAILURE'}"
|
|
97
|
+
if result.success?
|
|
98
|
+
puts "Value: #{result.value.inspect}"
|
|
99
|
+
else
|
|
100
|
+
puts "Error: #{result.error.inspect}"
|
|
101
|
+
puts "Details: #{result.error.details.inspect}" if result.error.respond_to?(:details)
|
|
102
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
en:
|
|
2
|
+
next_station_validations:
|
|
3
|
+
or: "or"
|
|
4
|
+
errors:
|
|
5
|
+
unexpected_key: "is not allowed"
|
|
6
|
+
array?: "must be an array"
|
|
7
|
+
empty?: "must be empty"
|
|
8
|
+
excludes?: "must not include %{value}"
|
|
9
|
+
excluded_from?:
|
|
10
|
+
arg:
|
|
11
|
+
default: "must not be one of: %{list}"
|
|
12
|
+
range: "must not be one of: %{list_left} - %{list_right}"
|
|
13
|
+
exclusion?: "must not be one of: %{list}"
|
|
14
|
+
eql?: "must be equal to %{left}"
|
|
15
|
+
not_eql?: "must not be equal to %{left}"
|
|
16
|
+
filled?: "must be filled"
|
|
17
|
+
format?: "is in invalid format"
|
|
18
|
+
number?: "must be a number"
|
|
19
|
+
odd?: "must be odd"
|
|
20
|
+
even?: "must be even"
|
|
21
|
+
gt?: "must be greater than %{num}"
|
|
22
|
+
gteq?: "must be greater than or equal to %{num}"
|
|
23
|
+
hash?: "must be a hash"
|
|
24
|
+
included_in?:
|
|
25
|
+
arg:
|
|
26
|
+
default: "must be one of: %{list}"
|
|
27
|
+
range: "must be one of: %{list_left} - %{list_right}"
|
|
28
|
+
inclusion?: "must be one of: %{list}"
|
|
29
|
+
includes?: "must include %{value}"
|
|
30
|
+
bool?: "must be boolean"
|
|
31
|
+
true?: "must be true"
|
|
32
|
+
false?: "must be false"
|
|
33
|
+
int?: "must be an integer"
|
|
34
|
+
float?: "must be a float"
|
|
35
|
+
decimal?: "must be a decimal"
|
|
36
|
+
date?: "must be a date"
|
|
37
|
+
date_time?: "must be a date time"
|
|
38
|
+
time?: "must be a time"
|
|
39
|
+
key?: "is missing"
|
|
40
|
+
attr?: "is missing"
|
|
41
|
+
lt?: "must be less than %{num}"
|
|
42
|
+
lteq?: "must be less than or equal to %{num}"
|
|
43
|
+
max_size?: "size cannot be greater than %{num}"
|
|
44
|
+
max_bytesize?: "bytesize cannot be greater than %{num}"
|
|
45
|
+
min_size?: "size cannot be less than %{num}"
|
|
46
|
+
min_bytesize?: "bytesize cannot be less than %{num}"
|
|
47
|
+
nil?: "cannot be defined"
|
|
48
|
+
str?: "must be a string"
|
|
49
|
+
type?: "must be %{type}"
|
|
50
|
+
respond_to?: "must respond to %{method}"
|
|
51
|
+
size?:
|
|
52
|
+
arg:
|
|
53
|
+
default: "size must be %{size}"
|
|
54
|
+
range: "size must be within %{size_left} - %{size_right}"
|
|
55
|
+
value:
|
|
56
|
+
string:
|
|
57
|
+
arg:
|
|
58
|
+
default: "length must be %{size}"
|
|
59
|
+
range: "length must be within %{size_left} - %{size_right}"
|
|
60
|
+
bytesize?:
|
|
61
|
+
arg:
|
|
62
|
+
default: "must be %{size} bytes long"
|
|
63
|
+
range: "must be within %{size_left} - %{size_right} bytes long"
|
|
64
|
+
uri?: "is not a valid URI"
|
|
65
|
+
uuid_v1?: "is not a valid UUID"
|
|
66
|
+
uuid_v2?: "is not a valid UUID"
|
|
67
|
+
uuid_v3?: "is not a valid UUID"
|
|
68
|
+
uuid_v4?: "is not a valid UUID"
|
|
69
|
+
uuid_v5?: "is not a valid UUID"
|
|
70
|
+
uuid_v6?: "is not a valid UUID"
|
|
71
|
+
uuid_v7?: "is not a valid UUID"
|
|
72
|
+
uuid_v8?: "is not a valid UUID"
|
|
73
|
+
not:
|
|
74
|
+
empty?: "cannot be empty"
|
|
75
|
+
|
|
76
|
+
sp:
|
|
77
|
+
next_station_validations:
|
|
78
|
+
or: "o"
|
|
79
|
+
errors:
|
|
80
|
+
unexpected_key: "no está permitido"
|
|
81
|
+
array?: "debe ser un arreglo"
|
|
82
|
+
empty?: "debe estar vacío"
|
|
83
|
+
excludes?: "no debe incluir %{value}"
|
|
84
|
+
excluded_from?:
|
|
85
|
+
arg:
|
|
86
|
+
default: "no debe ser uno de: %{list}"
|
|
87
|
+
range: "no debe ser uno de: %{list_left} - %{list_right}"
|
|
88
|
+
exclusion?: "no debe ser uno de: %{list}"
|
|
89
|
+
eql?: "debe ser igual a %{left}"
|
|
90
|
+
not_eql?: "no debe ser igual a %{left}"
|
|
91
|
+
filled?: "debe estar lleno"
|
|
92
|
+
format?: "tiene un formato inválido"
|
|
93
|
+
number?: "debe ser un número"
|
|
94
|
+
odd?: "debe ser impar"
|
|
95
|
+
even?: "debe ser par"
|
|
96
|
+
gt?: "debe ser mayor que %{num}"
|
|
97
|
+
gteq?: "debe ser mayor o igual que %{num}"
|
|
98
|
+
hash?: "debe ser un hash"
|
|
99
|
+
included_in?:
|
|
100
|
+
arg:
|
|
101
|
+
default: "debe ser uno de: %{list}"
|
|
102
|
+
range: "debe ser uno de: %{list_left} - %{list_right}"
|
|
103
|
+
inclusion?: "debe ser uno de: %{list}"
|
|
104
|
+
includes?: "debe incluir %{value}"
|
|
105
|
+
bool?: "debe ser booleano"
|
|
106
|
+
true?: "debe ser verdadero"
|
|
107
|
+
false?: "debe ser falso"
|
|
108
|
+
int?: "debe ser un número entero"
|
|
109
|
+
float?: "debe ser un número flotante"
|
|
110
|
+
decimal?: "debe ser un número decimal"
|
|
111
|
+
date?: "debe ser una fecha"
|
|
112
|
+
date_time?: "debe ser una fecha y hora"
|
|
113
|
+
time?: "debe ser una hora"
|
|
114
|
+
key?: "está ausente"
|
|
115
|
+
attr?: "está ausente"
|
|
116
|
+
lt?: "debe ser menor que %{num}"
|
|
117
|
+
lteq?: "debe ser menor o igual que %{num}"
|
|
118
|
+
max_size?: "el tamaño no puede ser mayor que %{num}"
|
|
119
|
+
max_bytesize?: "el tamaño en bytes no puede ser mayor que %{num}"
|
|
120
|
+
min_size?: "el tamaño no puede ser menor que %{num}"
|
|
121
|
+
min_bytesize?: "el tamaño en bytes no puede ser menor que %{num}"
|
|
122
|
+
nil?: "no puede estar definido"
|
|
123
|
+
str?: "debe ser una cadena de texto"
|
|
124
|
+
type?: "debe ser %{type}"
|
|
125
|
+
respond_to?: "debe responder al método %{method}"
|
|
126
|
+
size?:
|
|
127
|
+
arg:
|
|
128
|
+
default: "el tamaño debe ser %{size}"
|
|
129
|
+
range: "el tamaño debe estar entre %{size_left} y %{size_right}"
|
|
130
|
+
value:
|
|
131
|
+
string:
|
|
132
|
+
arg:
|
|
133
|
+
default: "la longitud debe ser %{size}"
|
|
134
|
+
range: "la longitud debe estar entre %{size_left} y %{size_right}"
|
|
135
|
+
bytesize?:
|
|
136
|
+
arg:
|
|
137
|
+
default: "debe tener %{size} bytes de longitud"
|
|
138
|
+
range: "debe tener entre %{size_left} y %{size_right} bytes de longitud"
|
|
139
|
+
uri?: "no es un URI válido"
|
|
140
|
+
uuid_v1?: "no es un UUID válido"
|
|
141
|
+
uuid_v2?: "no es un UUID válido"
|
|
142
|
+
uuid_v3?: "no es un UUID válido"
|
|
143
|
+
uuid_v4?: "no es un UUID válido"
|
|
144
|
+
uuid_v5?: "no es un UUID válido"
|
|
145
|
+
uuid_v6?: "no es un UUID válido"
|
|
146
|
+
uuid_v7?: "no es un UUID válido"
|
|
147
|
+
uuid_v8?: "no es un UUID válido"
|
|
148
|
+
not:
|
|
149
|
+
empty?: "no puede estar vacío"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry-configurable'
|
|
4
|
+
require 'dry-monitor'
|
|
5
|
+
require 'logger'
|
|
6
|
+
require_relative 'environment'
|
|
7
|
+
require_relative 'logging/formatters/json'
|
|
8
|
+
require_relative 'logging/formatters/console'
|
|
9
|
+
|
|
10
|
+
module NextStation
|
|
11
|
+
extend Dry::Configurable
|
|
12
|
+
|
|
13
|
+
# Define the environment
|
|
14
|
+
setting :environment, default: Environment.new, constructor: ->(v) {
|
|
15
|
+
if v.is_a?(String)
|
|
16
|
+
env = Environment.new
|
|
17
|
+
env.current = v
|
|
18
|
+
env
|
|
19
|
+
else
|
|
20
|
+
v
|
|
21
|
+
end
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Define the monitor
|
|
25
|
+
setting :monitor, default: (
|
|
26
|
+
monitor = Dry::Monitor::Notifications.new(:next_station)
|
|
27
|
+
monitor.register_event('operation.start')
|
|
28
|
+
monitor.register_event('operation.stop')
|
|
29
|
+
monitor.register_event('step.start')
|
|
30
|
+
monitor.register_event('step.stop')
|
|
31
|
+
monitor.register_event('step.retry')
|
|
32
|
+
monitor.register_event('log.custom')
|
|
33
|
+
monitor
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Define the default logger (STDOUT)
|
|
37
|
+
setting :logger, default: Logger.new($stdout)
|
|
38
|
+
|
|
39
|
+
# Enable/disable default logging subscribers
|
|
40
|
+
setting :logging_enabled, default: true
|
|
41
|
+
|
|
42
|
+
# Default logging level (:info, :debug)
|
|
43
|
+
setting :logging_level, default: :info
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
require_relative 'logging'
|
|
47
|
+
|
|
48
|
+
# Automatically setup logging if enabled
|
|
49
|
+
NextStation::Logging.setup! if NextStation.config.logging_enabled
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NextStation
|
|
4
|
+
# Detects the current environment (e.g., development, production)
|
|
5
|
+
# based on a configurable set of environment variables.
|
|
6
|
+
class Environment
|
|
7
|
+
attr_accessor :env_vars, :production_names, :development_names
|
|
8
|
+
attr_writer :current
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
# A list of common environment variables to check for the environment name.
|
|
12
|
+
@env_vars = %w[RAILS_ENV RACK_ENV APP_ENV RUBY_ENV]
|
|
13
|
+
|
|
14
|
+
# Names that are considered to be a "production" environment.
|
|
15
|
+
@production_names = %w[production prod prd]
|
|
16
|
+
|
|
17
|
+
# Names that are considered to be a "development" environment.
|
|
18
|
+
@development_names = %w[development dev]
|
|
19
|
+
|
|
20
|
+
# Manually set environment name.
|
|
21
|
+
@current = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the current environment name. Defaults to 'development' if none is found.
|
|
25
|
+
# @return [String]
|
|
26
|
+
def current
|
|
27
|
+
@current || env_vars.map { |var| ENV[var] }.compact.first || 'development'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Checks if the current environment is production.
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def production?
|
|
33
|
+
production_names.include?(current)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Checks if the current environment is development.
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def development?
|
|
39
|
+
development_names.include?(current)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NextStation
|
|
4
|
+
class Errors
|
|
5
|
+
def self.inherited(subclass)
|
|
6
|
+
super
|
|
7
|
+
subclass.extend(SharedErrorsDSL)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module SharedErrorsDSL
|
|
11
|
+
def error_type(type, &block)
|
|
12
|
+
@dsl ||= NextStation::Operation::ErrorsDSL.new
|
|
13
|
+
@dsl.error_type(type, &block)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def definitions
|
|
17
|
+
@dsl&.definitions || {}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module NextStation
|
|
6
|
+
module Logging
|
|
7
|
+
module Formatter
|
|
8
|
+
class Console < Logger::Formatter
|
|
9
|
+
# ANSI color codes
|
|
10
|
+
SEVERITY_COLORS = {
|
|
11
|
+
"DEBUG" => "\e[36m", # cyan
|
|
12
|
+
"INFO" => "\e[32m", # green
|
|
13
|
+
"WARN" => "\e[33m", # yellow
|
|
14
|
+
"ERROR" => "\e[31m", # red
|
|
15
|
+
"FATAL" => "\e[35m" # magenta
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
OPERATION_COLOR = "\e[34m" # blue
|
|
19
|
+
STEP_COLOR = "\e[90m" # gray
|
|
20
|
+
RESET_COLOR = "\e[0m"
|
|
21
|
+
|
|
22
|
+
def call(severity, datetime, _progname, msg)
|
|
23
|
+
msg = msg.is_a?(Hash) ? msg : { message: msg.to_s }
|
|
24
|
+
|
|
25
|
+
operation = msg[:operation]
|
|
26
|
+
step_name = msg[:step_name] ? "/#{msg[:step_name]}" : ""
|
|
27
|
+
payload = msg[:payload] unless msg[:payload].to_h.empty?
|
|
28
|
+
|
|
29
|
+
sev = "#{SEVERITY_COLORS[severity]}#{severity[0]}#{RESET_COLOR}"
|
|
30
|
+
op = "#{OPERATION_COLOR}#{operation}#{RESET_COLOR}"
|
|
31
|
+
step = step_name.empty? ? "" : "#{STEP_COLOR}#{step_name}#{RESET_COLOR}"
|
|
32
|
+
|
|
33
|
+
"[#{sev}][#{datetime.strftime('%Y-%m-%d %H:%M:%S')}][#{op}#{step}] -- #{msg[:message]} #{payload}\n"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module NextStation
|
|
7
|
+
module Logging
|
|
8
|
+
module Formatter
|
|
9
|
+
# A custom logger formatter that outputs log entries as JSON objects.
|
|
10
|
+
#
|
|
11
|
+
# This formatter is designed to work with the standard `Logger` class.
|
|
12
|
+
# It structures log messages into a JSON format that includes severity, timestamp,
|
|
13
|
+
# process ID, and structured data from the operation. It also automatically
|
|
14
|
+
# includes OpenTelemetry trace and span IDs if the `opentelemetry-sdk` is present.
|
|
15
|
+
class Json < Logger::Formatter
|
|
16
|
+
# Avoid repeated defined? calls in a hot path
|
|
17
|
+
OTEL_AVAILABLE = defined?(::OpenTelemetry::Trace)
|
|
18
|
+
|
|
19
|
+
# Formats the log entry into a JSON string.
|
|
20
|
+
#
|
|
21
|
+
# @param severity [String] The log severity (e.g., 'INFO', 'WARN').
|
|
22
|
+
# @param time [Time] The timestamp of the log event.
|
|
23
|
+
# @param _progname [String] The program name (unused).
|
|
24
|
+
# @param msg [String, Hash] The log message. If a Hash, it is treated as
|
|
25
|
+
# structured data with keys like `:message`, `:payload`, and `:operation`.
|
|
26
|
+
# If a String, it becomes the value of the `:message` key.
|
|
27
|
+
# @return [String] The formatted log entry as a JSON string, terminated
|
|
28
|
+
# with a newline character.
|
|
29
|
+
def call(severity, time, _progname, msg)
|
|
30
|
+
data = msg.is_a?(Hash) ? msg : { message: msg.to_s }
|
|
31
|
+
|
|
32
|
+
log_entry = {
|
|
33
|
+
level: severity,
|
|
34
|
+
time: time.utc.strftime('%Y-%m-%dT%H:%M:%S.%6N'),
|
|
35
|
+
pid: Process.pid,
|
|
36
|
+
origin: build_origin(data),
|
|
37
|
+
message: data[:message]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
add_payload_to_log_entry(log_entry, data) if data[:payload]
|
|
41
|
+
|
|
42
|
+
add_otel_context(log_entry) if OTEL_AVAILABLE
|
|
43
|
+
|
|
44
|
+
# Compact the hash to remove nil values and ensure a newline
|
|
45
|
+
JSON.generate(log_entry.compact) << "\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Adds the payload to the log entry if it is not empty.
|
|
51
|
+
def add_payload_to_log_entry(log_entry, data)
|
|
52
|
+
return unless data[:payload]
|
|
53
|
+
|
|
54
|
+
log_entry[:payload] = data[:payload] unless data[:payload].empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Constructs the origin hash, returning nil if no origin data is present.
|
|
58
|
+
# This prevents an empty "origin": {} from appearing in the logs.
|
|
59
|
+
def build_origin(data)
|
|
60
|
+
origin = {}
|
|
61
|
+
origin[:operation] = data[:operation] if data[:operation]
|
|
62
|
+
origin[:event] = data[:event_kind] if data[:event_kind]
|
|
63
|
+
origin[:step_name] = data[:step_name] if data[:step_name]
|
|
64
|
+
origin[:step_attempt] = data[:step_attempt] if data[:step_attempt]
|
|
65
|
+
|
|
66
|
+
origin.empty? ? nil : origin
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Adds OpenTelemetry trace and span IDs to the log entry if available.
|
|
70
|
+
def add_otel_context(log_entry)
|
|
71
|
+
context = ::OpenTelemetry::Trace.current_span.context
|
|
72
|
+
if context.valid?
|
|
73
|
+
log_entry[:trace_id] = context.hex_trace_id
|
|
74
|
+
log_entry[:span_id] = context.hex_span_id
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NextStation
|
|
4
|
+
module Logging
|
|
5
|
+
module Subscribers
|
|
6
|
+
# @api private
|
|
7
|
+
class Base
|
|
8
|
+
# Map levels to their numeric priority for comparison.
|
|
9
|
+
LEVELS = {
|
|
10
|
+
debug: 0,
|
|
11
|
+
info: 1,
|
|
12
|
+
warn: 2,
|
|
13
|
+
error: 3,
|
|
14
|
+
fatal: 4,
|
|
15
|
+
unknown: 5
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Subscribes a new instance to the monitor.
|
|
19
|
+
# @param monitor [Dry::Monitor::Notifications]
|
|
20
|
+
def self.subscribe(monitor)
|
|
21
|
+
new.subscribe(monitor)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Subscribes to the event(s) in the monitor.
|
|
25
|
+
# @param monitor [Dry::Monitor::Notifications]
|
|
26
|
+
def subscribe(monitor)
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
protected
|
|
31
|
+
|
|
32
|
+
# Default log level if none is provided in the event.
|
|
33
|
+
# @return [Symbol]
|
|
34
|
+
def default_level
|
|
35
|
+
:info
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Logs the event data to the configured logger.
|
|
39
|
+
# @param event [Dry::Monitor::Event]
|
|
40
|
+
# @param level [Symbol, nil] Explicit level, or derived from event/default.
|
|
41
|
+
# @param extra_data [Hash] Additional data to merge into the log entry.
|
|
42
|
+
def log_event(event, level: nil, extra_data: {})
|
|
43
|
+
# Add this check to respect `config.logging_enabled = false`
|
|
44
|
+
return unless NextStation.config.logging_enabled
|
|
45
|
+
|
|
46
|
+
event_data = event.to_h
|
|
47
|
+
log_level = level || event_data.delete(:level) || default_level
|
|
48
|
+
|
|
49
|
+
# Filter by logging_level
|
|
50
|
+
return unless level_sufficient?(log_level)
|
|
51
|
+
|
|
52
|
+
# Merge data while preserving the original event data
|
|
53
|
+
payload = event_data.merge(extra_data)
|
|
54
|
+
|
|
55
|
+
# We pass the whole hash. The Formatter will pick what it needs.
|
|
56
|
+
NextStation.config.logger.send(log_level, payload)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# @param log_level [Symbol] The level of the current log event.
|
|
62
|
+
# @return [Boolean] True if the log level is equal or higher than configured.
|
|
63
|
+
def level_sufficient?(log_level)
|
|
64
|
+
configured_level = NextStation.config.logging_level
|
|
65
|
+
LEVELS.fetch(log_level, 1) >= LEVELS.fetch(configured_level, 1)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module NextStation
|
|
6
|
+
module Logging
|
|
7
|
+
module Subscribers
|
|
8
|
+
# Subscriber for custom log events manually triggered.
|
|
9
|
+
# @api private
|
|
10
|
+
class Custom < Base
|
|
11
|
+
# @param monitor [Dry::Monitor::Notifications]
|
|
12
|
+
def subscribe(monitor)
|
|
13
|
+
monitor.subscribe('log.custom') { |event| on_custom(event) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param event [Dry::Monitor::Event]
|
|
17
|
+
def on_custom(event)
|
|
18
|
+
log_event(event, extra_data: {
|
|
19
|
+
event_kind: 'log.custom'
|
|
20
|
+
})
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module NextStation
|
|
6
|
+
module Logging
|
|
7
|
+
module Subscribers
|
|
8
|
+
# Subscriber for operation lifecycle events.
|
|
9
|
+
# @api private
|
|
10
|
+
class Operation < Base
|
|
11
|
+
# @param monitor [Dry::Monitor::Notifications]
|
|
12
|
+
def subscribe(monitor)
|
|
13
|
+
monitor.subscribe('operation.start') { |event| on_start(event) }
|
|
14
|
+
monitor.subscribe('operation.stop') { |event| on_stop(event) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param event [Dry::Monitor::Event]
|
|
18
|
+
def on_start(event)
|
|
19
|
+
log_event(event, extra_data: {
|
|
20
|
+
message: "Started operation: #{event[:operation]}",
|
|
21
|
+
event_kind: 'operation.start'
|
|
22
|
+
})
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param event [Dry::Monitor::Event]
|
|
26
|
+
def on_stop(event)
|
|
27
|
+
result_status = event[:result].success? ? 'success' : 'failure'
|
|
28
|
+
|
|
29
|
+
log_event(event, extra_data: {
|
|
30
|
+
message: "completed operation: #{event[:operation]} with #{result_status}",
|
|
31
|
+
event_kind: 'operation.stop',
|
|
32
|
+
payload: {
|
|
33
|
+
duration: event[:duration],
|
|
34
|
+
result: result_status
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module NextStation
|
|
6
|
+
module Logging
|
|
7
|
+
module Subscribers
|
|
8
|
+
# Subscriber for step lifecycle events.
|
|
9
|
+
# @api private
|
|
10
|
+
class Step < Base
|
|
11
|
+
# @param monitor [Dry::Monitor::Notifications]
|
|
12
|
+
def subscribe(monitor)
|
|
13
|
+
monitor.subscribe('step.start') { |event| on_start(event) }
|
|
14
|
+
monitor.subscribe('step.stop') { |event| on_stop(event) }
|
|
15
|
+
monitor.subscribe('step.retry') { |event| on_retry(event) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param event [Dry::Monitor::Event]
|
|
19
|
+
def on_start(event)
|
|
20
|
+
log_event(event, level: :debug, extra_data: {
|
|
21
|
+
message: "Started step: #{event[:step]} in #{event[:operation]}",
|
|
22
|
+
event_kind: 'step.start',
|
|
23
|
+
step_name: event[:step],
|
|
24
|
+
operation: event[:operation]
|
|
25
|
+
})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param event [Dry::Monitor::Event]
|
|
29
|
+
def on_stop(event)
|
|
30
|
+
log_event(event, level: :debug, extra_data: {
|
|
31
|
+
message: "Completed step: #{event[:step]} in #{event[:operation]}",
|
|
32
|
+
event_kind: 'step.stop',
|
|
33
|
+
step_name: event[:step],
|
|
34
|
+
operation: event[:operation],
|
|
35
|
+
payload: {
|
|
36
|
+
duration: event[:duration]
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param event [Dry::Monitor::Event]
|
|
42
|
+
def on_retry(event)
|
|
43
|
+
log_event(event, level: :warn, extra_data: {
|
|
44
|
+
message: "Retrying step: #{event[:step]} (attempt #{event[:attempt]})",
|
|
45
|
+
event_kind: 'step.retry',
|
|
46
|
+
step_name: event[:step],
|
|
47
|
+
operation: event[:operation],
|
|
48
|
+
step_attempt: event[:attempt]
|
|
49
|
+
})
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|