jekyll-l10n 1.5.0 → 1.6.1

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: 21d081d43cd7503f98fb38d64526397eaf5ff3164e0eb3d6525aa8d88290b9d7
4
- data.tar.gz: 382bb065f52fed1d762c2cd5110afc349de73a4780d564f813e8a7be4929373b
3
+ metadata.gz: 6a5b62e4bb13db9fc2b93ad555425b1819c6e26aa64189c8ca31a009c55b861a
4
+ data.tar.gz: fe68489182fa03670d02e537366dfb25f4c85d12f233cf1f785d5cf2a8b36dac
5
5
  SHA512:
6
- metadata.gz: cab0e3382f953d8f88acc764be019ccdb02bf382e65a4f227f99206e7e6c4370e4a6b158da960cf7a762e28d84765f75917ee0f351cfcd2b434aecd8d8bb8969
7
- data.tar.gz: f5dc58255fd89efe2014d63e9a916d9ff3ff04d20ebf67a0d863469fd082bee7d3b399586a1f32392f78b2e4f1e6cb7c26254986faacaf0b1208a6fe5ed0c3f6
6
+ metadata.gz: 5a0822316cb1cb87bfb5dba17c2bdaa43c224df30b6f52157a061dc2de85432df43fa38715a7a8ffa05c68b48d282c0f727d7e64fa84491d14565272625ff998
7
+ data.tar.gz: 002d73f80ff7bb1ca6f2133f3d39cbc3f6ed82d2a7997b300261f1514c99aea05d30a18f9cdfa1ebe748a57f2d932cafbf2e9a79f466dbb92d18007b801e11c4
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module L10n
5
+ # OpenTelemetry instrumentation facade for jekyll-l10n.
6
+ #
7
+ # All tracing is configured centrally in TRACED_METHODS — no span code lives
8
+ # in business logic classes. To add a span: append one entry. To rename a
9
+ # method: update the one entry. When a method is removed from its class the
10
+ # stale entry raises NoMethodError in tests, signalling the entry to delete.
11
+ #
12
+ # Requires opentelemetry-api at runtime; falls back to a no-op if absent.
13
+ # Users opt in to real tracing by adding opentelemetry-sdk and
14
+ # opentelemetry-exporter-otlp to their site Gemfile and exporting:
15
+ #
16
+ # OTEL_SERVICE_NAME=jekyll-l10n
17
+ # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
18
+ module Instrumentation
19
+ TRACER_NAME = 'jekyll-l10n'
20
+
21
+ # Central instrumentation registry.
22
+ #
23
+ # Each row: [class_name, method_type, method_name, span_name, attribute_proc]
24
+ #
25
+ # method_type:
26
+ # :instance — public instance method
27
+ # :private — private instance method (visibility is preserved on the wrapper)
28
+ # :class — class / module method (prepended on the singleton class)
29
+ #
30
+ # attribute_proc: ->(span, this, args, result) or nil
31
+ # span — OTel span (or NoopSpan); call span.set_attribute after super returns
32
+ # this — receiver object (instance or nil for class methods)
33
+ # args — positional args array as passed to the method
34
+ # result — return value of the original method
35
+ # :nocov: — attribute procs are configuration data; coverage comes from integration tests
36
+
37
+ # Helpers for common attribute patterns used inside TRACED_METHODS procs.
38
+ def self.hash_val(hash, key)
39
+ hash.is_a?(Hash) ? hash[key].to_i : 0
40
+ end
41
+
42
+ def self.hash_size(hash)
43
+ hash.is_a?(Hash) ? hash.size : 0
44
+ end
45
+
46
+ def self.array_size(val)
47
+ val.is_a?(Array) ? val.size : 0
48
+ end
49
+
50
+ TRACED_METHODS = [
51
+ # ── Jekyll integration ────────────────────────────────────────────────
52
+ ['Jekyll::L10n::Generator', :instance, :generate, 'l10n.generate',
53
+ lambda { |span, _this, args, _result|
54
+ span.set_attribute('l10n.site_page_count', args[0]&.pages&.size || 0)
55
+ }],
56
+
57
+ ['Jekyll::L10n::PostWriteProcessor', :instance, :process_localizations,
58
+ 'l10n.post_write', nil],
59
+
60
+ # translate is the post_render hook entry point — one span per localized page
61
+ ['Jekyll::L10n::Translator', :instance, :translate, 'l10n.translate_render',
62
+ lambda { |span, this, _args, _result|
63
+ span.set_attribute('l10n.locale', this.page.data['locale'].to_s)
64
+ span.set_attribute('l10n.page_url', this.page.url.to_s)
65
+ }],
66
+
67
+ # ── Extraction pipeline ───────────────────────────────────────────────
68
+ ['Jekyll::L10n::Extractor', :instance, :extract_site, 'l10n.extract_site',
69
+ lambda { |span, _this, _args, result|
70
+ span.set_attribute('l10n.file_count', Instrumentation.hash_val(result, :files_processed))
71
+ }],
72
+
73
+ # process_file is the per-page body called from the html_files loop
74
+ ['Jekyll::L10n::Extractor', :private, :process_file, 'l10n.extract_page',
75
+ lambda { |span, _this, args, result|
76
+ span.set_attribute('l10n.page_path', args[0].to_s)
77
+ span.set_attribute('l10n.strings_extracted',
78
+ Instrumentation.hash_val(result, :strings_extracted))
79
+ }],
80
+
81
+ ['Jekyll::L10n::HtmlStringExtractor', :instance, :extract, 'l10n.html_extract',
82
+ lambda { |span, _this, args, result|
83
+ span.set_attribute('l10n.html_size_bytes', args[0].bytesize)
84
+ span.set_attribute('l10n.extracted_count', Instrumentation.array_size(result))
85
+ }],
86
+
87
+ ['Jekyll::L10n::ExtractionResultSaver', :instance, :save_results, 'l10n.po_file_write',
88
+ lambda { |span, _this, args, result|
89
+ span.set_attribute('l10n.page_path', args[2].to_s)
90
+ span.set_attribute('l10n.entry_count', Instrumentation.array_size(args[1]))
91
+ span.set_attribute('l10n.po_files_created',
92
+ Instrumentation.hash_val(result, :po_files_created))
93
+ }],
94
+
95
+ ['Jekyll::L10n::CompendiumMerger', :instance, :merge_compendia,
96
+ 'l10n.compendium_merge', nil],
97
+
98
+ ['Jekyll::L10n::CompendiumTranslator', :instance, :translate_compendia,
99
+ 'l10n.translate_compendia',
100
+ lambda { |span, _this, args, _result|
101
+ config = args[0]
102
+ span.set_attribute('l10n.locale_count', config.locales.size) if config.respond_to?(:locales)
103
+ }],
104
+
105
+ # ── Translation pipeline ──────────────────────────────────────────────
106
+ ['Jekyll::L10n::PostWriteHtmlReprocessor', :instance, :reprocess_localized_pages,
107
+ 'l10n.reprocess_localized_pages', nil],
108
+
109
+ # translate_html_file is the per-page body called from the localized_files loop
110
+ ['Jekyll::L10n::PostWriteHtmlReprocessor', :private, :translate_html_file,
111
+ 'l10n.translate_page',
112
+ lambda { |span, _this, args, _result|
113
+ span.set_attribute('l10n.page_path', args[0].to_s)
114
+ span.set_attribute('l10n.locale', args[1].to_s)
115
+ }],
116
+
117
+ ['Jekyll::L10n::PageTranslationLoader', :class, :load, 'l10n.translation_load',
118
+ lambda { |span, _this, args, result|
119
+ span.set_attribute('l10n.locale', args[1].to_s)
120
+ span.set_attribute('l10n.page_path', args[2].to_s)
121
+ span.set_attribute('l10n.entry_count', Instrumentation.hash_size(result))
122
+ }],
123
+
124
+ ['Jekyll::L10n::HtmlTranslator', :instance, :translate, 'l10n.dom_translate',
125
+ lambda { |span, this, args, _result|
126
+ span.set_attribute('l10n.locale', (args[2] || 'en').to_s)
127
+ span.set_attribute('l10n.fallback_mode', this.fallback_mode.to_s)
128
+ }],
129
+
130
+ ['Jekyll::L10n::LibreTranslator', :private, :make_api_request,
131
+ 'l10n.libretranslate_batch',
132
+ lambda { |span, _this, args, _result|
133
+ span.set_attribute('l10n.locale', args[1].to_s)
134
+ span.set_attribute('l10n.batch_size', args[0].is_a?(Array) ? args[0].size : 1)
135
+ }],
136
+
137
+ # ── Utilities ─────────────────────────────────────────────────────────
138
+ ['Jekyll::L10n::HtmlParser', :class, :parse_document, 'l10n.html_parse',
139
+ lambda { |span, _this, args, _result|
140
+ span.set_attribute('l10n.html_size_bytes', args[0].bytesize)
141
+ }],
142
+
143
+ ['Jekyll::L10n::UrlTransformer', :class, :transform_document, 'l10n.url_transform',
144
+ lambda { |span, _this, args, _result|
145
+ span.set_attribute('l10n.locale', args[1].to_s)
146
+ doc = args[0]
147
+ span.set_attribute('l10n.href_count',
148
+ doc.respond_to?(:css) ? doc.css('a[href]').size : 0)
149
+ }],
150
+
151
+ ['Jekyll::L10n::ExternalLinkIconPreserver', :class, :preserve,
152
+ 'l10n.icon_preserve', nil],
153
+
154
+ # ── PO file operations ────────────────────────────────────────────────
155
+ ['Jekyll::L10n::PoFileReader', :instance, :parse_for_translation,
156
+ 'l10n.po_file_read',
157
+ lambda { |span, this, _args, result|
158
+ span.set_attribute('l10n.file_path', this.po_path.to_s)
159
+ span.set_attribute('l10n.entry_count', Instrumentation.hash_size(result))
160
+ }],
161
+
162
+ ['Jekyll::L10n::PoFileMerger', :class, :merge_for_locale, 'l10n.po_merge',
163
+ lambda { |span, _this, args, result|
164
+ span.set_attribute('l10n.locale', args[2].to_s)
165
+ span.set_attribute('l10n.merged_count', Instrumentation.hash_size(result))
166
+ }]
167
+ ].freeze
168
+ # :nocov:
169
+
170
+ # Returns the active OTel tracer, or a no-op tracer if opentelemetry-api is absent.
171
+ def self.tracer
172
+ @tracer ||=
173
+ if defined?(OpenTelemetry)
174
+ # :nocov:
175
+ OpenTelemetry.tracer_provider.tracer(TRACER_NAME, Jekyll::L10n::VERSION)
176
+ # :nocov:
177
+ else
178
+ NoopTracer.new
179
+ end
180
+ end
181
+
182
+ # Wraps a block in an OTel span.
183
+ #
184
+ # @param span_name [String] Dot-separated span name (e.g. 'l10n.extract_page')
185
+ # @param attributes [Hash] Initial span attributes
186
+ # @yieldparam span [OpenTelemetry::Trace::Span, NoopSpan] Active span
187
+ # @return [Object] The return value of the block
188
+ def self.instrument(span_name, attributes: {}, &block)
189
+ tracer.in_span(span_name, attributes: attributes, &block)
190
+ end
191
+
192
+ # Resets the cached tracer and installation flag. Call in tests after changing OTel configuration.
193
+ def self.reset!
194
+ @tracer = nil
195
+ @installed = false
196
+ end
197
+
198
+ # Returns true when OTel is requested via standard environment variables.
199
+ #
200
+ # install! guards on this so the prepend wrappers are only applied when
201
+ # a real exporter is configured. In CI and local tests (no OTel env vars)
202
+ # business logic classes are untouched, keeping allow_any_instance_of stubs
203
+ # and other RSpec mechanics fully functional.
204
+ def self.enabled?
205
+ ENV.key?('OTEL_EXPORTER_OTLP_ENDPOINT') || ENV.key?('OTEL_SERVICE_NAME')
206
+ end
207
+
208
+ # Installs wrappers on all classes listed in TRACED_METHODS using Module#prepend.
209
+ #
210
+ # Called once at plugin load time (end of jekyll-l10n.rb, after all requires),
211
+ # but only when enabled? returns true. Use OTEL_EXPORTER_OTLP_ENDPOINT or
212
+ # OTEL_SERVICE_NAME to activate tracing.
213
+ def self.install!
214
+ return if @installed
215
+
216
+ @installed = true
217
+ setup_sdk!
218
+ TRACED_METHODS.group_by { |e| e[0] }.each do |class_name, entries|
219
+ prepend_wrappers(resolve_class(class_name), entries)
220
+ end
221
+ end
222
+
223
+ # Configures the OTel SDK from standard env vars if the SDK gem is available.
224
+ #
225
+ # Called from install! so that spans are exported to the configured backend
226
+ # without requiring sites to add a separate initializer. Silently no-ops when
227
+ # opentelemetry-sdk or opentelemetry-exporter-otlp is not installed.
228
+ #
229
+ # SimpleSpanProcessor is used instead of the SDK default (BatchSpanProcessor)
230
+ # because Jekyll is a short-lived CLI process. BatchSpanProcessor exports on a
231
+ # 5-second schedule; outer spans that close last (post_write, generate) are
232
+ # silently dropped when the process exits before the next flush. Simple exports
233
+ # each span synchronously the moment it closes, guaranteeing no span loss.
234
+ #
235
+ # jekyll-theme-centos loads several instrumented plugins in one process,
236
+ # and OpenTelemetry::SDK.configure unconditionally overwrites the global
237
+ # tracer_provider on every call — so only the first plugin to reach this
238
+ # method (see sdk_already_configured?) actually configures the SDK; every
239
+ # later plugin's call becomes a no-op and reuses that provider. The
240
+ # at_exit and post_write hooks below are therefore registered once, by
241
+ # whichever plugin wins, and cover every plugin's spans, not just this one's.
242
+ def self.setup_sdk!
243
+ return if sdk_already_configured?
244
+
245
+ # :nocov:
246
+ require 'opentelemetry/sdk'
247
+ require 'opentelemetry/exporter/otlp'
248
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter.new
249
+ processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
250
+ OpenTelemetry::SDK.configure { |c| c.add_span_processor(processor) }
251
+
252
+ at_exit { OpenTelemetry.tracer_provider&.shutdown }
253
+ Jekyll::Hooks.register(:site, :post_write, priority: 0) { OpenTelemetry.tracer_provider&.force_flush }
254
+ rescue LoadError
255
+ nil
256
+ # :nocov:
257
+ end
258
+
259
+ # True when some plugin (this one or another) already installed a real
260
+ # SDK-backed tracer_provider, in which case setup_sdk! must not run again.
261
+ def self.sdk_already_configured?
262
+ defined?(OpenTelemetry::SDK::Trace::TracerProvider) &&
263
+ OpenTelemetry.tracer_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
264
+ end
265
+
266
+ # Resolves a dot-separated class name to a constant; returns nil on NameError.
267
+ def self.resolve_class(name)
268
+ name.split('::').reduce(Object) { |m, c| m.const_get(c) }
269
+ rescue NameError
270
+ nil
271
+ end
272
+
273
+ # Applies instance and class-method wrappers to klass; skips if klass is nil.
274
+ def self.prepend_wrappers(klass, entries)
275
+ return unless klass
276
+
277
+ class_entries, instance_entries = entries.partition { |e| e[1] == :class }
278
+ klass.prepend(build_wrapper_module(instance_entries)) unless instance_entries.empty?
279
+ klass.singleton_class.prepend(build_wrapper_module(class_entries)) unless class_entries.empty?
280
+ end
281
+
282
+ # Builds an anonymous module that wraps each listed method in a span.
283
+ def self.build_wrapper_module(entries)
284
+ Module.new do
285
+ entries.each do |_class_name, method_type, method_name, span_name, attr_proc|
286
+ define_method(method_name) do |*args, **kwargs, &blk|
287
+ Instrumentation.instrument(span_name) do |span|
288
+ result = super(*args, **kwargs, &blk)
289
+ attr_proc&.call(span, self, args, result)
290
+ result
291
+ rescue StandardError => e
292
+ span.record_exception(e)
293
+ raise
294
+ end
295
+ end
296
+ private method_name if method_type == :private
297
+ end
298
+ end
299
+ end
300
+
301
+ private_class_method :build_wrapper_module, :prepend_wrappers, :setup_sdk!, :sdk_already_configured?
302
+
303
+ # No-op tracer used when opentelemetry-api is not loaded.
304
+ class NoopTracer
305
+ def in_span(_name, **_opts)
306
+ yield NoopSpan.new
307
+ end
308
+ end
309
+
310
+ # No-op span that silently accepts all attribute and event calls.
311
+ class NoopSpan
312
+ def set_attribute(*)
313
+ self
314
+ end
315
+
316
+ def record_exception(*)
317
+ self
318
+ end
319
+
320
+ def status=(*); end
321
+ end
322
+ end
323
+ end
324
+ end
@@ -36,6 +36,8 @@ module Jekyll
36
36
  MSGSTR_PATTERN = /^msgstr ['"](.*)['"] *$/.freeze unless const_defined?(:MSGSTR_PATTERN)
37
37
  NO_REFERENCE = nil unless const_defined?(:NO_REFERENCE)
38
38
 
39
+ attr_reader :po_path
40
+
39
41
  # Initialize a new PoFileReader.
40
42
  #
41
43
  # Accepts either a file path (if file exists) or inline PO content. Determines
data/lib/jekyll-l10n.rb CHANGED
@@ -170,6 +170,7 @@ require 'gettext/po'
170
170
  # @see Jekyll::L10n::UrlFilter for liquid filters
171
171
 
172
172
  require_relative 'jekyll-l10n/version'
173
+ require_relative 'jekyll-l10n/instrumentation'
173
174
  require_relative 'jekyll-l10n/constants'
174
175
  require_relative 'jekyll-l10n/errors'
175
176
  require_relative 'jekyll-l10n/po_file/reader'
@@ -271,3 +272,5 @@ Jekyll::Hooks.register :site, :post_write, priority: :high do |site|
271
272
  end
272
273
 
273
274
  Liquid::Template.register_filter(Jekyll::L10n::UrlFilter)
275
+
276
+ Jekyll::L10n::Instrumentation.install! if Jekyll::L10n::Instrumentation.enabled?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-l10n
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alain Reguera Delgado
@@ -69,6 +69,20 @@ dependencies:
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
71
  version: '2.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: opentelemetry-api
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - "~>"
77
+ - !ruby/object:Gem::Version
78
+ version: '1.4'
79
+ type: :runtime
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - "~>"
84
+ - !ruby/object:Gem::Version
85
+ version: '1.4'
72
86
  - !ruby/object:Gem::Dependency
73
87
  name: bundler
74
88
  requirement: !ruby/object:Gem::Requirement
@@ -230,6 +244,7 @@ files:
230
244
  - lib/jekyll-l10n/extraction/html_string_extractor.rb
231
245
  - lib/jekyll-l10n/extraction/logger.rb
232
246
  - lib/jekyll-l10n/extraction/result_saver.rb
247
+ - lib/jekyll-l10n/instrumentation.rb
233
248
  - lib/jekyll-l10n/jekyll/generator.rb
234
249
  - lib/jekyll-l10n/jekyll/localized_page.rb
235
250
  - lib/jekyll-l10n/jekyll/post_write_html_reprocessor.rb