Add OpenTelemetry with Rack instrumentation
Gated on OTEL_EXPORTER_OTLP_ENDPOINT — no gems loaded or SDK
initialized when unset. The SDK reads endpoint, headers, and
protocol directly from standard OTEL_EXPORTER_OTLP_* env vars.

Moved CONFIG initialization from db.rb to config.rb so it is
available at require-time without depending on load order.

Closes #29

Assisted-by: Claude Opus 4.6 via pi
change ynxsrxmvyrylkompsmwzupplurktqqsx
commit 49781d4ea0086facedf6042c2e6344be0c92ce3e
author Alpha Chen <alpha@kejadlen.dev>
date
parent szpwrrrl
diff --git a/Gemfile b/Gemfile
index 9469cef..bc8d3e5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -12,6 +12,10 @@ gem "sentry-ruby"
 gem "sequel"
 gem "sqlite3"
 
+gem "opentelemetry-sdk"
+gem "opentelemetry-exporter-otlp"
+gem "opentelemetry-instrumentation-rack"
+
 group :development do
   gem "ferrum"
   gem "minitest"
diff --git a/Gemfile.lock b/Gemfile.lock
index e1eb94a..58247b4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -40,6 +40,11 @@ GEM
     ffi (1.17.3-x86_64-linux-gnu)
     ffi (1.17.3-x86_64-linux-musl)
     fileutils (1.8.0)
+    google-protobuf (4.33.5)
+      bigdecimal
+      rake (>= 13)
+    googleapis-common-protos-types (1.22.0)
+      google-protobuf (~> 4.26)
     i18n (1.14.8)
       concurrent-ruby (~> 1.0)
     json (2.18.1)
@@ -53,6 +58,31 @@ GEM
       prism (~> 1.5)
     mutex_m (0.3.0)
     nio4r (2.7.5)
+    opentelemetry-api (1.7.0)
+    opentelemetry-common (0.23.0)
+      opentelemetry-api (~> 1.0)
+    opentelemetry-exporter-otlp (0.31.1)
+      google-protobuf (>= 3.18)
+      googleapis-common-protos-types (~> 1.3)
+      opentelemetry-api (~> 1.1)
+      opentelemetry-common (~> 0.20)
+      opentelemetry-sdk (~> 1.10)
+      opentelemetry-semantic_conventions
+    opentelemetry-instrumentation-base (0.25.0)
+      opentelemetry-api (~> 1.7)
+      opentelemetry-common (~> 0.21)
+      opentelemetry-registry (~> 0.1)
+    opentelemetry-instrumentation-rack (0.29.0)
+      opentelemetry-instrumentation-base (~> 0.25)
+    opentelemetry-registry (0.4.0)
+      opentelemetry-api (~> 1.1)
+    opentelemetry-sdk (1.10.0)
+      opentelemetry-api (~> 1.1)
+      opentelemetry-common (~> 0.20)
+      opentelemetry-registry (~> 0.2)
+      opentelemetry-semantic_conventions
+    opentelemetry-semantic_conventions (1.36.0)
+      opentelemetry-api (~> 1.0)
     parser (3.3.10.1)
       ast (~> 2.4.1)
       racc
@@ -155,6 +185,9 @@ PLATFORMS
 DEPENDENCIES
   ferrum
   minitest
+  opentelemetry-exporter-otlp
+  opentelemetry-instrumentation-rack
+  opentelemetry-sdk
   phlex
   puma
   rack-test
@@ -190,6 +223,8 @@ CHECKSUMS
   ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
   ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
   fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+  google-protobuf (4.33.5) sha256=1b64fb774c101b23ac3f6923eca24be04fd971635d235c4cd4cfe0d752620da0
+  googleapis-common-protos-types (1.22.0) sha256=f97492b77bd6da0018c860d5004f512fe7cd165554d7019a8f4df6a56fbfc4c7
   i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
   json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
   language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
@@ -198,6 +233,14 @@ CHECKSUMS
   minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
   mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
   nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+  opentelemetry-api (1.7.0) sha256=ccfd264ea6f2db5bf4185e3c07a1297977b44a944e2ce65457c4fe63a697214f
+  opentelemetry-common (0.23.0) sha256=da721190479d57bae0ad2207468f47f3e2c3b9a91024b5bc32c9d280183eb32c
+  opentelemetry-exporter-otlp (0.31.1) sha256=5358be17d7849cbcc4f49e1fc24105edc780a6f96c8e57b64192ab9a8e47474a
+  opentelemetry-instrumentation-base (0.25.0) sha256=642a3a7f08354e6e969423327a4fa67ed2cca7ac6fe5ee09e55b17d1c576da27
+  opentelemetry-instrumentation-rack (0.29.0) sha256=9e2cbb8336087064cbe33b502d917d85b174162bc717efda1cfdbd182342f377
+  opentelemetry-registry (0.4.0) sha256=903fa6bfaa29eac1c1d73a4fdd29b850977b5353b84b8cdff11222c00ad2968f
+  opentelemetry-sdk (1.10.0) sha256=43719949be8df24dcaeb86ebbf75636cda87d51a01af2729499b92a48b80521a
+  opentelemetry-semantic_conventions (1.36.0) sha256=c1b1607dbc7853aac7f9e23f6e8b76969c45b07f2b812a4aa4383c19a3b0f617
   parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
   phlex (2.4.1) sha256=e596717fbfe38b5271840266758779ebe75092e02629f0c170287e6290a70b12
   prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
diff --git a/config.ru b/config.ru
index 431becc..224b661 100644
--- a/config.ru
+++ b/config.ru
@@ -8,7 +8,20 @@ unless ENV["COMMIT_SHA"]
   ENV["BUILD_DATE"] = Time.now.strftime("%Y-%m-%d")
 end
 
-require_relative "lib/ketchup/web"
+require_relative "lib/ketchup/config"
+
+if CONFIG.otel
+  require "opentelemetry/sdk"
+  require "opentelemetry/exporter/otlp"
+  require "opentelemetry/instrumentation/rack"
+
+  OpenTelemetry::SDK.configure do |c|
+    c.service_name = "ketchup"
+    c.use "OpenTelemetry::Instrumentation::Rack"
+  end
+
+  use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args)
+end
 
 $stderr.puts CONFIG
 
@@ -29,4 +42,5 @@ if CONFIG.default_user
   use Ketchup::DevAuth, CONFIG.default_user
 end
 
+require_relative "lib/ketchup/web"
 run Web.freeze.app
diff --git a/lib/ketchup/config.rb b/lib/ketchup/config.rb
index 28f964d..41fb9e5 100644
--- a/lib/ketchup/config.rb
+++ b/lib/ketchup/config.rb
@@ -7,6 +7,7 @@ Config = Data.define(
   :database_url, #: String
   :session_secret, #: String
   :sentry, #: SentryConfig?
+  :otel, #: OtelConfig?
   :default_user, #: DefaultUser?
   :commit_sha, #: String?
   :change_id, #: String?
@@ -19,6 +20,10 @@ class Config
     :env, #: String?
   )
 
+  OtelConfig = Data.define(
+    :endpoint, #: String
+  )
+
   DefaultUser = Data.define(
     :login, #: String
     :name, #: String
@@ -37,6 +42,7 @@ class Config
   def to_s
     parts = ["database=#{database_url}"]
     parts << "sentry=#{sentry.env || "on"}" if sentry
+    parts << "otel=on" if otel
     parts << "default_user=#{default_user.login}" if default_user
     parts << "commit=#{commit_sha}" if commit_sha
     parts << "change=#{change_id}" if change_id
@@ -47,11 +53,13 @@ class Config
   #: (?Hash[String, String] env) -> Config
   def self.from_env(env = ENV)
     sentry_dsn = env["SENTRY_DSN"]
+    otel_endpoint = env["OTEL_EXPORTER_OTLP_ENDPOINT"]
     default_user = env["DEFAULT_USER"]
     new(
       database_url: env.fetch("DATABASE_URL") { "db/ketchup.db" },
       session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
       sentry: sentry_dsn ? SentryConfig.new(dsn: sentry_dsn, env: env["SENTRY_ENV"]) : nil,
+      otel: otel_endpoint ? OtelConfig.new(endpoint: otel_endpoint) : nil,
       default_user: default_user ? DefaultUser.parse(default_user) : nil,
       commit_sha: env["COMMIT_SHA"],
       change_id: env["CHANGE_ID"]&.slice(0, 8),
@@ -59,3 +67,5 @@ class Config
     )
   end
 end
+
+CONFIG = Config.from_env
diff --git a/lib/ketchup/db.rb b/lib/ketchup/db.rb
index d21088a..7deb168 100644
--- a/lib/ketchup/db.rb
+++ b/lib/ketchup/db.rb
@@ -4,7 +4,6 @@ require "sequel"
 
 require_relative "config"
 
-CONFIG = Config.from_env
 DB = Sequel.sqlite(CONFIG.database_url)
 Sequel.extension :migration
 Sequel::Migrator.run(DB, File.expand_path("../../db/migrate", __dir__))