legionio 1.7.21 → 1.7.25

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: f1c44047319bdcf02eb061d73517101f695f380d976cb65ad9e2a0d2afb824b2
4
- data.tar.gz: e756182d3fdfea35882961412f7d3093884d7477954c3b67d2037039ad310189
3
+ metadata.gz: d65ff77ca667361b558a97d1da1c3d5d2b1090bb4aad5559532406cd18e781a7
4
+ data.tar.gz: 7583822cc4cf804703b8a733be36bb711d1cae70724a49dc98cafea6583268ab
5
5
  SHA512:
6
- metadata.gz: 1178b2efaadc7801e37a68fd556add983eede7acd3f01dd1c744b029c8e048dc7ac681db0b578fc545419ca8d4f447cc518d3f7d5c2b351323350c1ae9ea908c
7
- data.tar.gz: c9f34008bef206a7108cbc4013213342c4bc25fa6396884fec1e471b3b6d46d7110027214a0829c05a0d59d59c348fad8726d089c03ffbb1b40ce5f7d9de10a7
6
+ metadata.gz: 06f7712d8c9c6e944890ba2c55efbdf3463a6c39e906017cede6d3401215de9144e7c916c39c8b981e8f6ea6646a5439bcc10a399443fe7642b3ab06e3ef2c7a
7
+ data.tar.gz: 5e5819fc7973e9cb6de4b64e88c40b74d033fd666c4b9585726a3b1e87b9468f6cf4759fed226a9c3a7bc755a10b9267403dcd001d2c10f08b7aaf36e6264812
data/CHANGELOG.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # Legion Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [1.7.25] - 2026-04-06
4
+
5
+ ### Added
6
+ - Wire Format Phase 3 Group 2: `Identity::Request::SOURCE_NORMALIZATION` constant — maps middleware-emitted source values (`:api_key`, `:local`, `:jwt`, `:kerberos`, `:system`) to canonical credential enum at `from_auth_context` construction time
7
+ - Wire Format Phase 3 Group 2: `response_meta` in `API::Helpers` now includes `caller` block (`canonical_name`, `kind`, `source`) when the request is authenticated and `env['legion.principal']` is set by `Identity::Middleware`
8
+ - Wire Format Phase 3 Group 2: `POST /api/llm/inference` wires `to_caller_hash` from the authenticated principal into the pipeline `caller:` field, replacing the hardcoded `{ type: :user, credential: :api }` fallback
9
+
10
+ ## [1.7.24] - 2026-04-06
11
+
12
+ ### Fixed
13
+ - `Routes::Events` SSE stream: qualify `stream_queue` call with `Routes::Events.` to fix NoMethodError on Legion::API instance
14
+
15
+ ### Added
16
+ - `Identity::Process.source` accessor — exposes provider source in identity hash (Wire Format Phase 3)
17
+ - `source:` key in `Identity::Process.identity_hash`, `bind!`, `bind_fallback!`, and `EMPTY_STATE`
18
+
19
+ ## [1.7.22] - 2026-04-06
20
+ ### Added
21
+ - Elastic APM integration for Sinatra API via `elastic-apm` gem
22
+ - Full APM config under `api.elastic_apm` settings: server_url, api_key, secret_token, api_buffer_size, api_request_size, api_request_time, capture_body, capture_headers, capture_env, disable_send, enabled, environment, hostname, ignore_url_patterns, pool_size, service_name, service_node_name, service_version, sample_rate
23
+ - `setup_apm` / `shutdown_apm` lifecycle in Service (boot, shutdown, reload)
24
+ - `ElasticAPM::Middleware` wired into API when available
25
+ - Health/ready endpoints excluded from APM tracing by default
4
26
 
5
27
  ## [1.7.21] - 2026-04-06
6
28
  ### Fixed
@@ -13,7 +13,8 @@ module Legion
13
13
  puma: puma_defaults,
14
14
  bind_retries: 3,
15
15
  bind_retry_wait: 2,
16
- tls: tls_defaults
16
+ tls: tls_defaults,
17
+ elastic_apm: elastic_apm_defaults
17
18
  }
18
19
  end
19
20
 
@@ -31,6 +32,33 @@ module Legion
31
32
  enabled: false
32
33
  }
33
34
  end
35
+
36
+ def self.elastic_apm_defaults
37
+ {
38
+ enabled: false,
39
+ server_url: 'http://localhost:8200',
40
+ api_key: nil,
41
+ secret_token: nil,
42
+ api_buffer_size: 256,
43
+ api_request_size: '750kb',
44
+ api_request_time: '10s',
45
+ capture_body: 'off',
46
+ capture_headers: true,
47
+ capture_env: true,
48
+ disable_send: false,
49
+ environment: nil,
50
+ hostname: nil,
51
+ ignore_url_patterns: %w[/api/health /api/ready],
52
+ pool_size: 1,
53
+ service_name: 'LegionIO',
54
+ service_node_name: nil,
55
+ service_version: nil,
56
+ sample_rate: 1.0,
57
+ verify_server_cert: true,
58
+ central_config: true,
59
+ span_frames_min_duration: '5ms'
60
+ }
61
+ end
34
62
  end
35
63
  end
36
64
  end
@@ -90,7 +90,7 @@ module Legion
90
90
  end
91
91
 
92
92
  stream do |out|
93
- stream_queue(out: out, queue: queue, listener: listener)
93
+ Routes::Events.stream_queue(out: out, queue: queue, listener: listener)
94
94
  end
95
95
  end
96
96
 
@@ -173,10 +173,23 @@ module Legion
173
173
  private
174
174
 
175
175
  def response_meta
176
- {
176
+ meta = {
177
177
  timestamp: Time.now.utc.iso8601,
178
178
  node: Legion::Settings[:client][:name]
179
179
  }
180
+
181
+ if authenticated? && defined?(Legion::Identity::Request)
182
+ req = env['legion.principal']
183
+ if req
184
+ meta[:caller] = {
185
+ canonical_name: req.canonical_name,
186
+ kind: req.kind,
187
+ source: req.source
188
+ }
189
+ end
190
+ end
191
+
192
+ meta
180
193
  end
181
194
 
182
195
  def page_limit
@@ -280,12 +280,19 @@ module Legion
280
280
  require 'legion/llm/pipeline/request' unless defined?(Legion::LLM::Pipeline::Request)
281
281
  require 'legion/llm/pipeline/executor' unless defined?(Legion::LLM::Pipeline::Executor)
282
282
 
283
+ principal = defined?(Legion::Identity::Request) && env['legion.principal']
284
+ caller_ctx = if principal
285
+ principal.to_caller_hash
286
+ else
287
+ { requested_by: { identity: caller_identity, type: :user, credential: :api } }
288
+ end
289
+
283
290
  req = Legion::LLM::Pipeline::Request.build(
284
291
  messages: messages,
285
292
  system: body[:system],
286
293
  routing: { provider: provider, model: model },
287
294
  tools: tool_classes,
288
- caller: { requested_by: { identity: caller_identity, type: :user, credential: :api } },
295
+ caller: caller_ctx,
289
296
  conversation_id: body[:conversation_id],
290
297
  metadata: { requested_tools: requested_tools },
291
298
  stream: streaming,
@@ -10,19 +10,64 @@ module Legion
10
10
 
11
11
  def call(env)
12
12
  method_path = "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}"
13
- Legion::Logging.info "[api][request-start] #{method_path}"
13
+ client_info = build_client_info(env)
14
+ Legion::Logging.info "[api][request-start] #{method_path} #{client_info}"
14
15
  start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
15
16
  status, headers, body = @app.call(env)
16
17
  duration = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
17
18
 
18
19
  level = duration > 5000 ? :warn : :info
19
- Legion::Logging.send(level, "[api] #{method_path} #{status} #{duration}ms")
20
+ Legion::Logging.send(level, "[api] #{method_path} #{status} #{duration}ms #{client_info}")
20
21
  [status, headers, body]
21
22
  rescue StandardError => e
22
23
  duration = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
23
- Legion::Logging.error "[api] #{method_path} 500 #{duration}ms - #{e.message}"
24
+ Legion::Logging.error "[api] #{method_path} 500 #{duration}ms #{client_info} - #{e.message}"
24
25
  raise
25
26
  end
27
+
28
+ private
29
+
30
+ def build_client_info(env)
31
+ ip = env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-'
32
+ ua = env['HTTP_USER_AGENT'] || '-'
33
+ origin = env['HTTP_ORIGIN'] || '-'
34
+ referer = env['HTTP_REFERER'] || '-'
35
+ auth = env['HTTP_AUTHORIZATION'] ? 'Bearer(present)' : 'none'
36
+ content_type = env['CONTENT_TYPE'] || '-'
37
+ content_length = env['CONTENT_LENGTH'] || '-'
38
+ query = env['QUERY_STRING'] && env['QUERY_STRING'].empty? ? nil : env['QUERY_STRING']
39
+
40
+ parts = [
41
+ "ip=#{ip}",
42
+ "ua=#{ua}",
43
+ "origin=#{origin}",
44
+ "referer=#{referer}",
45
+ "auth=#{auth}",
46
+ "content_type=#{content_type}",
47
+ "content_length=#{content_length}"
48
+ ]
49
+ parts << "query=#{query}" if query
50
+ parts.join(' ')
51
+ end
52
+
53
+ def peek_body(env)
54
+ input = env['rack.input']
55
+ return '-' unless input.respond_to?(:read) && input.respond_to?(:rewind)
56
+
57
+ begin
58
+ input.rewind
59
+ raw = input.read(1024)
60
+ raw.to_s.gsub(/\s+/, ' ')[0, 512]
61
+ rescue StandardError
62
+ '-'
63
+ ensure
64
+ begin
65
+ input.rewind
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+ end
26
71
  end
27
72
  end
28
73
  end
data/lib/legion/api.rb CHANGED
@@ -222,5 +222,7 @@ module Legion
222
222
 
223
223
  use Legion::API::Middleware::RequestLogger
224
224
  use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
225
+ use ElasticAPM::Middleware if defined?(ElasticAPM::Middleware) &&
226
+ Legion::Settings.dig(:api, :elastic_apm, :enabled)
225
227
  end
226
228
  end
@@ -11,6 +11,7 @@ module Legion
11
11
  id: nil,
12
12
  canonical_name: nil,
13
13
  kind: nil,
14
+ source: nil,
14
15
  persistent: false,
15
16
  groups: [].freeze,
16
17
  metadata: {}.freeze
@@ -53,11 +54,16 @@ module Legion
53
54
  @state.get[:persistent] == true
54
55
  end
55
56
 
57
+ def source
58
+ @state.get[:source]
59
+ end
60
+
56
61
  def identity_hash
57
62
  {
58
63
  id: id,
59
64
  canonical_name: canonical_name,
60
65
  kind: kind,
66
+ source: source,
61
67
  mode: mode,
62
68
  queue_prefix: queue_prefix,
63
69
  resolved: resolved?,
@@ -69,10 +75,12 @@ module Legion
69
75
 
70
76
  def bind!(provider, identity_hash)
71
77
  @provider = provider
78
+ provider_source = provider.respond_to?(:provider_name) ? provider.provider_name : nil
72
79
  @state.set({
73
80
  id: identity_hash[:id],
74
81
  canonical_name: identity_hash[:canonical_name],
75
82
  kind: identity_hash[:kind],
83
+ source: identity_hash.key?(:source) ? identity_hash[:source] : provider_source,
76
84
  persistent: identity_hash.fetch(:persistent, true),
77
85
  groups: Array(identity_hash[:groups]).compact.freeze,
78
86
  metadata: identity_hash[:metadata].is_a?(Hash) ? identity_hash[:metadata].dup.freeze : {}.freeze
@@ -86,6 +94,7 @@ module Legion
86
94
  id: nil,
87
95
  canonical_name: user,
88
96
  kind: :human,
97
+ source: :system,
89
98
  persistent: false,
90
99
  groups: [].freeze,
91
100
  metadata: {}.freeze
@@ -3,6 +3,19 @@
3
3
  module Legion
4
4
  module Identity
5
5
  class Request
6
+ # Maps middleware-emitted source values to the canonical credential enum.
7
+ # :local is emitted by Middleware#system_principal for unauthenticated loopback
8
+ # requests and must normalize to :system to maintain audit trail consistency.
9
+ # :jwt is intentionally kept distinct — JWT is the transport, not the provider.
10
+ # Entra-specific identification requires issuer inspection (Phase 7 concern).
11
+ SOURCE_NORMALIZATION = {
12
+ api_key: :api,
13
+ jwt: :jwt,
14
+ kerberos: :kerberos,
15
+ local: :system,
16
+ system: :system
17
+ }.freeze
18
+
6
19
  attr_reader :principal_id, :canonical_name, :kind, :groups, :source, :metadata
7
20
 
8
21
  def initialize(principal_id:, canonical_name:, kind:, groups: [], source: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
@@ -10,7 +23,7 @@ module Legion
10
23
  @canonical_name = canonical_name
11
24
  @kind = kind
12
25
  @groups = groups.freeze
13
- @source = source
26
+ @source = SOURCE_NORMALIZATION.fetch(source&.to_sym, source)
14
27
  @metadata = metadata.freeze
15
28
  freeze
16
29
  end
@@ -23,16 +36,19 @@ module Legion
23
36
 
24
37
  # Builds a Request from a parsed auth claims hash with symbol keys:
25
38
  # { sub:, name:, preferred_username:, kind:, groups:, source: }
39
+ # The source value is normalized via SOURCE_NORMALIZATION at construction time.
26
40
  def self.from_auth_context(claims_hash)
27
41
  raw_name = claims_hash[:name] || claims_hash[:preferred_username] || ''
28
42
  canonical = raw_name.to_s.strip.downcase.gsub('.', '-')
43
+ raw_source = claims_hash[:source]&.to_sym
44
+ normalized_source = SOURCE_NORMALIZATION.fetch(raw_source, raw_source)
29
45
 
30
46
  new(
31
47
  principal_id: claims_hash[:sub],
32
48
  canonical_name: canonical,
33
49
  kind: claims_hash[:kind] || :human,
34
50
  groups: claims_hash[:groups] || [],
35
- source: claims_hash[:source]
51
+ source: normalized_source
36
52
  )
37
53
  end
38
54
 
@@ -327,21 +327,12 @@ module Legion
327
327
  end
328
328
 
329
329
  def setup_apm
330
- apm_settings = Legion::Settings[:apm] || {}
330
+ apm_settings = Legion::Settings.dig(:api, :elastic_apm) || {}
331
331
  return unless apm_settings[:enabled]
332
332
 
333
333
  require 'elastic-apm'
334
334
 
335
- config = {
336
- service_name: apm_settings[:service_name] || "legion-#{Legion::Settings[:client][:name]}",
337
- server_url: apm_settings[:server_url] || 'http://localhost:8200',
338
- environment: apm_settings[:environment] || Legion::Settings[:environment] || 'development',
339
- secret_token: apm_settings[:secret_token],
340
- api_key: apm_settings[:api_key],
341
- log_level: apm_settings[:log_level]&.to_sym || Logger::WARN,
342
- transaction_sample_rate: apm_settings[:sample_rate] || 1.0
343
- }.compact
344
-
335
+ config = build_apm_config(apm_settings)
345
336
  ElasticAPM.start(**config)
346
337
  @apm_running = true
347
338
  log.info "Elastic APM started: server=#{config[:server_url]} service=#{config[:service_name]}"
@@ -1145,6 +1136,35 @@ module Legion
1145
1136
  }.compact
1146
1137
  end
1147
1138
 
1139
+ def build_apm_config(apm)
1140
+ {
1141
+ server_url: apm[:server_url] || 'http://localhost:8200',
1142
+ api_key: apm[:api_key],
1143
+ secret_token: apm[:secret_token],
1144
+ api_buffer_size: apm[:api_buffer_size] || 256,
1145
+ api_request_size: apm[:api_request_size] || '750kb',
1146
+ api_request_time: apm[:api_request_time] || '10s',
1147
+ capture_body: apm.fetch(:capture_body, 'off'),
1148
+ capture_headers: apm.fetch(:capture_headers, true),
1149
+ capture_env: apm.fetch(:capture_env, true),
1150
+ disable_send: apm.fetch(:disable_send, false),
1151
+ environment: apm[:environment] || Legion::Settings[:environment] || 'development',
1152
+ framework_name: 'LegionIO',
1153
+ framework_version: Legion::VERSION,
1154
+ hostname: apm[:hostname] || Legion::Settings[:client][:name],
1155
+ ignore_url_patterns: apm[:ignore_url_patterns] || %w[/api/health /api/ready],
1156
+ logger: Legion::Logging.log,
1157
+ pool_size: apm[:pool_size] || 1,
1158
+ service_name: apm[:service_name] || 'LegionIO',
1159
+ service_node_name: apm[:service_node_name] || Legion::Settings[:client][:name],
1160
+ service_version: apm[:service_version] || Legion::VERSION,
1161
+ transaction_sample_rate: apm[:sample_rate] || 1.0,
1162
+ verify_server_cert: apm.fetch(:verify_server_cert, true),
1163
+ central_config: apm.fetch(:central_config, true),
1164
+ span_frames_min_duration: apm[:span_frames_min_duration]
1165
+ }.compact
1166
+ end
1167
+
1148
1168
  def ssl_server_settings(tls_cfg, bind, port)
1149
1169
  return {} unless tls_cfg
1150
1170
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.21'
4
+ VERSION = '1.7.25'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.21
4
+ version: 1.7.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity