prompt_canary 0.3.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +86 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/CONTRIBUTING.md +45 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +338 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/prompt_canary/application_controller.rb +6 -0
  9. data/app/controllers/prompt_canary/dashboard/prompts_controller.rb +69 -0
  10. data/app/views/layouts/prompt_canary/application.html.erb +42 -0
  11. data/app/views/prompt_canary/dashboard/prompts/index.html.erb +50 -0
  12. data/app/views/prompt_canary/dashboard/prompts/show.html.erb +114 -0
  13. data/config/routes.rb +12 -0
  14. data/examples/auto_rollback.rb +105 -0
  15. data/examples/demo.rb +83 -0
  16. data/exe/prompt_canary +6 -0
  17. data/lib/generators/prompt_canary/install_generator.rb +39 -0
  18. data/lib/generators/prompt_canary/templates/create_prompt_canary_calls.rb +62 -0
  19. data/lib/prompt_canary/adapter_factory.rb +16 -0
  20. data/lib/prompt_canary/adapters/anthropic.rb +39 -0
  21. data/lib/prompt_canary/adapters/base.rb +11 -0
  22. data/lib/prompt_canary/cli/commands/history.rb +63 -0
  23. data/lib/prompt_canary/cli/commands/status.rb +55 -0
  24. data/lib/prompt_canary/cli.rb +69 -0
  25. data/lib/prompt_canary/configuration.rb +31 -0
  26. data/lib/prompt_canary/deployment.rb +186 -0
  27. data/lib/prompt_canary/engine.rb +7 -0
  28. data/lib/prompt_canary/monitor.rb +30 -0
  29. data/lib/prompt_canary/monitor_job.rb +13 -0
  30. data/lib/prompt_canary/prompt.rb +13 -0
  31. data/lib/prompt_canary/prompt_executor.rb +27 -0
  32. data/lib/prompt_canary/promptable.rb +50 -0
  33. data/lib/prompt_canary/railtie.rb +28 -0
  34. data/lib/prompt_canary/recorder.rb +55 -0
  35. data/lib/prompt_canary/result.rb +18 -0
  36. data/lib/prompt_canary/rollback_rule.rb +22 -0
  37. data/lib/prompt_canary/router.rb +61 -0
  38. data/lib/prompt_canary/storage/active_record_adapter.rb +58 -0
  39. data/lib/prompt_canary/storage/memory.rb +21 -0
  40. data/lib/prompt_canary/storage/sqlite.rb +64 -0
  41. data/lib/prompt_canary/storage_factory.rb +24 -0
  42. data/lib/prompt_canary/version.rb +5 -0
  43. data/lib/prompt_canary/version_builder.rb +52 -0
  44. data/lib/prompt_canary/version_object.rb +63 -0
  45. data/lib/prompt_canary.rb +101 -0
  46. data/sig/prompt_canary.rbs +4 -0
  47. metadata +95 -0
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptCanary
4
+ class Router
5
+ def self.choose(prompt_class, context)
6
+ primary = db_primary(prompt_class) || prompt_class.primary_version
7
+ partial = prompt_class.versions.find { |v| v.partial_rollout? || v.predicate? }
8
+ return primary unless partial
9
+ return primary if demoted?(prompt_class.name, partial.name)
10
+
11
+ route_partial(partial, primary, context, prompt_class.name)
12
+ end
13
+
14
+ def self.route_partial(partial, primary, context, prompt_name)
15
+ return partial if partial.matches_predicate?(context)
16
+
17
+ call_id = context[:call_id]
18
+ return primary unless call_id
19
+
20
+ percent = canary_percent(prompt_name, partial.name, partial.rollout.fetch(:percent, 0))
21
+ Zlib.crc32(call_id.to_s) % 100 < percent ? partial : primary
22
+ end
23
+ private_class_method :route_partial
24
+
25
+ def self.canary_percent(prompt_name, version_name, default)
26
+ return default unless defined?(PromptCanary::RolloutOverride)
27
+
28
+ override = PromptCanary::RolloutOverride
29
+ .where(prompt: prompt_name, version: version_name)
30
+ .where("rollout_override > 0")
31
+ .first
32
+ override ? override.rollout_override : default
33
+ rescue ::ActiveRecord::ConnectionNotEstablished, ::ActiveRecord::StatementInvalid
34
+ default
35
+ end
36
+ private_class_method :canary_percent
37
+
38
+ def self.db_primary(prompt_class)
39
+ return nil unless defined?(PromptCanary::PrimaryOverride)
40
+
41
+ override = PromptCanary::PrimaryOverride.find_by(prompt: prompt_class.name)
42
+ return nil unless override
43
+
44
+ prompt_class.versions.find { |v| v.name == override.version }
45
+ rescue ::ActiveRecord::ConnectionNotEstablished, ::ActiveRecord::StatementInvalid
46
+ nil
47
+ end
48
+ private_class_method :db_primary
49
+
50
+ def self.demoted?(prompt_name, version_name)
51
+ return false unless defined?(PromptCanary::RolloutOverride)
52
+
53
+ PromptCanary::RolloutOverride
54
+ .where(prompt: prompt_name, version: version_name, rollout_override: 0)
55
+ .exists?
56
+ rescue ::ActiveRecord::ConnectionNotEstablished, ::ActiveRecord::StatementInvalid
57
+ false
58
+ end
59
+ private_class_method :demoted?
60
+ end
61
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "json"
5
+
6
+ module PromptCanary
7
+ class Call < ::ActiveRecord::Base
8
+ self.table_name = "prompt_canary_calls"
9
+ end
10
+
11
+ class RolloutOverride < ::ActiveRecord::Base
12
+ self.table_name = "prompt_canary_rollout_overrides"
13
+ end
14
+
15
+ class PrimaryOverride < ::ActiveRecord::Base
16
+ self.table_name = "prompt_canary_primary_overrides"
17
+ end
18
+
19
+ class PromptEvent < ::ActiveRecord::Base
20
+ self.table_name = "prompt_canary_events"
21
+ end
22
+
23
+ module Storage
24
+ class ActiveRecord
25
+ def write(record)
26
+ Call.create!(
27
+ prompt: record[:prompt],
28
+ version: record[:version],
29
+ latency_ms: record[:latency_ms],
30
+ tokens: record[:tokens]&.to_json,
31
+ error: record[:error]&.message,
32
+ recorded_at: record[:recorded_at]
33
+ )
34
+ end
35
+
36
+ def read_recent(prompt:, version:, limit:)
37
+ Call.where(prompt: prompt, version: version)
38
+ .order(recorded_at: :desc)
39
+ .limit(limit)
40
+ .reverse
41
+ .map { |row| deserialize(row) }
42
+ end
43
+
44
+ private
45
+
46
+ def deserialize(row)
47
+ {
48
+ prompt: row.prompt,
49
+ version: row.version,
50
+ latency_ms: row.latency_ms,
51
+ tokens: row.tokens ? JSON.parse(row.tokens, symbolize_names: true) : nil,
52
+ error: row.error ? StandardError.new(row.error) : nil,
53
+ recorded_at: row.recorded_at
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptCanary
4
+ module Storage
5
+ class Memory
6
+ def initialize
7
+ @records = []
8
+ end
9
+
10
+ def write(record)
11
+ @records << record
12
+ end
13
+
14
+ def read_recent(prompt:, version:, limit:)
15
+ @records
16
+ .select { |r| r[:prompt] == prompt && r[:version] == version }
17
+ .last(limit)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "json"
5
+
6
+ module PromptCanary
7
+ module Storage
8
+ class SQLite
9
+ def initialize(path: "prompt_canary.db")
10
+ @db = ::SQLite3::Database.new(path)
11
+ @db.results_as_hash = true
12
+ create_table
13
+ end
14
+
15
+ def write(record)
16
+ @db.execute(
17
+ "INSERT INTO calls (prompt, version, latency_ms, tokens, error, recorded_at) VALUES (?, ?, ?, ?, ?, ?)",
18
+ [
19
+ record[:prompt],
20
+ record[:version],
21
+ record[:latency_ms],
22
+ JSON.dump(record[:tokens]),
23
+ record[:error]&.message,
24
+ record[:recorded_at].iso8601
25
+ ]
26
+ )
27
+ end
28
+
29
+ def read_recent(prompt:, version:, limit:)
30
+ rows = @db.execute(
31
+ "SELECT * FROM calls WHERE prompt = ? AND version = ? ORDER BY recorded_at DESC LIMIT ?",
32
+ [prompt, version, limit]
33
+ )
34
+
35
+ rows.reverse.map do |row|
36
+ {
37
+ prompt: row["prompt"],
38
+ version: row["version"],
39
+ latency_ms: row["latency_ms"],
40
+ tokens: row["tokens"] ? JSON.parse(row["tokens"], symbolize_names: true) : nil,
41
+ error: row["error"] ? StandardError.new(row["error"]) : nil,
42
+ recorded_at: Time.parse(row["recorded_at"])
43
+ }
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def create_table
50
+ @db.execute(<<~SQL)
51
+ CREATE TABLE IF NOT EXISTS calls (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ prompt TEXT NOT NULL,
54
+ version TEXT NOT NULL,
55
+ latency_ms INTEGER,
56
+ tokens TEXT,
57
+ error TEXT,
58
+ recorded_at TEXT NOT NULL
59
+ )
60
+ SQL
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptCanary
4
+ class StorageFactory
5
+ REGISTRY = {
6
+ memory: -> { Storage::Memory.new },
7
+ sqlite: lambda {
8
+ require "prompt_canary/storage/sqlite"
9
+ Storage::SQLite.new
10
+ },
11
+ active_record: lambda {
12
+ require "prompt_canary/storage/active_record_adapter"
13
+ Storage::ActiveRecord.new
14
+ }
15
+ }.freeze
16
+
17
+ def self.build(storage_name)
18
+ builder = REGISTRY[storage_name]
19
+ raise ConfigurationError, "Unknown storage: #{storage_name.inspect}" unless builder
20
+
21
+ builder.call
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptCanary
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptCanary
4
+ class VersionBuilder
5
+ attr_reader :_name, :_model, :_system, :_rollout
6
+
7
+ def initialize(name)
8
+ @_name = name
9
+ @_rollout = {}
10
+ @_rollback_rules = []
11
+ end
12
+
13
+ def rollout(value)
14
+ @_rollout = value
15
+ end
16
+
17
+ def rollout_to(&block)
18
+ @_predicate = block
19
+ end
20
+
21
+ def rollback_if(metric, over:, greater_than: nil, less_than: nil)
22
+ comparator = if greater_than
23
+ :greater_than
24
+ elsif less_than
25
+ :less_than
26
+ else
27
+ raise ArgumentError, "rollback_if requires greater_than: or less_than:"
28
+ end
29
+ threshold = greater_than || less_than
30
+ @_rollback_rules << RollbackRule.new(metric: metric, threshold: threshold, comparator: comparator, window: over)
31
+ end
32
+
33
+ def model(value)
34
+ @_model = value
35
+ end
36
+
37
+ def system(value = nil, &block)
38
+ @_system = block || value
39
+ end
40
+
41
+ def build
42
+ Version.new(
43
+ name: _name,
44
+ model: _model,
45
+ system: _system,
46
+ rollout: _rollout,
47
+ predicate: @_predicate,
48
+ rollback_rules: @_rollback_rules
49
+ )
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module PromptCanary
6
+ class Version
7
+ attr_reader :name, :model, :system, :rollout, :rollback_rules
8
+
9
+ def initialize(name:, model:, system:, rollout:, predicate: nil, rollback_rules: [])
10
+ @name = name
11
+ @model = model
12
+ @system = system
13
+ @rollout = rollout
14
+ @predicate = predicate
15
+ @rollback_rules = rollback_rules
16
+ end
17
+
18
+ def system_for(args = {})
19
+ @system.respond_to?(:call) ? @system.call(args) : @system
20
+ end
21
+
22
+ def predicate?
23
+ !@predicate.nil?
24
+ end
25
+
26
+ def matches_predicate?(context)
27
+ return false unless @predicate
28
+
29
+ @predicate.call(context)
30
+ rescue StandardError
31
+ false
32
+ end
33
+
34
+ def partial_rollout?
35
+ rollout.fetch(:percent, 0).positive?
36
+ end
37
+
38
+ def routes?(key)
39
+ roll = Zlib.crc32(key.to_s) % 100
40
+ roll < rollout.fetch(:percent, 0)
41
+ end
42
+
43
+ def set_rollout!(percent)
44
+ @rollout = { percent: percent }
45
+ end
46
+
47
+ def demoted?
48
+ @demoted || false
49
+ end
50
+
51
+ def demote!
52
+ @demoted = true
53
+ @previous_rollout = @rollout.fetch(:percent, 0)
54
+ @rollout = { percent: 0 }
55
+ end
56
+
57
+ def restore!
58
+ @demoted = false
59
+ @rollout = { percent: @previous_rollout || 0 }
60
+ @previous_rollout = nil
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptCanary
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class DuplicateVersionError < Error; end
7
+ class NoPrimaryVersionError < Error; end
8
+ class UnknownVersionError < Error; end
9
+ class CannotDemotePrimaryError < Error; end
10
+ class DemotedVersionError < Error; end
11
+ end
12
+
13
+ require_relative "prompt_canary/deployment"
14
+
15
+ module PromptCanary
16
+ class << self
17
+ include Deployment
18
+
19
+ def configure
20
+ yield configuration
21
+ configuration.validate!
22
+ end
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def register_prompt(klass)
29
+ registered_prompts << klass
30
+ end
31
+
32
+ def registered_prompts
33
+ @registered_prompts ||= []
34
+ end
35
+
36
+ def reset_configuration!
37
+ @configuration = nil
38
+ @registered_prompts = nil
39
+ end
40
+
41
+ def load_prompt_classes(path, loader: method(:require))
42
+ return unless File.directory?(path)
43
+
44
+ Dir[File.join(path, "**", "*.rb")].sort.each { |f| loader.call(f) }
45
+ end
46
+
47
+ def check_storage_config!(logger)
48
+ return unless configuration.storage == :sqlite
49
+
50
+ logger.warn(
51
+ "[PromptCanary] storage: :sqlite is not recommended for multi-process Rails deployments. " \
52
+ "Run `rails generate prompt_canary:install && rails db:migrate` " \
53
+ "and set `storage: :active_record`."
54
+ )
55
+ end
56
+
57
+ def stats(prompt_class, version_name, over: 100)
58
+ recorder = Recorder.new(storage: StorageFactory.build(configuration.storage))
59
+ recorder.stats(prompt: prompt_class.name, version: version_name, over: over)
60
+ end
61
+
62
+ def subscribe(event, &block)
63
+ subscribers[event] << block
64
+ end
65
+
66
+ def reset_subscribers!
67
+ @subscribers = nil
68
+ end
69
+
70
+ private
71
+
72
+ def publish(event, payload = {})
73
+ subscribers[event].each { |sub| sub.call(payload) }
74
+ end
75
+
76
+ def subscribers
77
+ @subscribers ||= Hash.new { |h, k| h[k] = [] }
78
+ end
79
+ end
80
+ end
81
+
82
+ require_relative "prompt_canary/version"
83
+ require_relative "prompt_canary/configuration"
84
+ require_relative "prompt_canary/rollback_rule"
85
+ require_relative "prompt_canary/result"
86
+ require_relative "prompt_canary/version_object"
87
+ require_relative "prompt_canary/version_builder"
88
+ require_relative "prompt_canary/promptable"
89
+ require_relative "prompt_canary/prompt"
90
+ require_relative "prompt_canary/router"
91
+ require_relative "prompt_canary/adapters/base"
92
+ require_relative "prompt_canary/adapters/anthropic"
93
+ require_relative "prompt_canary/adapter_factory"
94
+ require_relative "prompt_canary/storage/memory"
95
+ require_relative "prompt_canary/storage_factory"
96
+ require_relative "prompt_canary/prompt_executor"
97
+ require_relative "prompt_canary/recorder"
98
+ require_relative "prompt_canary/monitor"
99
+ require_relative "prompt_canary/cli"
100
+ require_relative "prompt_canary/railtie" if defined?(Rails::Railtie)
101
+ require_relative "prompt_canary/engine" if defined?(Rails::Engine)
@@ -0,0 +1,4 @@
1
+ module PromptCanary
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: prompt_canary
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Rockwell Windsor Rice
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-06-04 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Declare prompts as Ruby classes with versioned configurations, route
14
+ traffic by percentage or predicate, record telemetry, and automatically roll back
15
+ misbehaving versions.
16
+ email:
17
+ - rockwellwindsor@gmail.com
18
+ executables:
19
+ - prompt_canary
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - CODE_OF_CONDUCT.md
25
+ - CONTRIBUTING.md
26
+ - LICENSE.txt
27
+ - README.md
28
+ - Rakefile
29
+ - app/controllers/prompt_canary/application_controller.rb
30
+ - app/controllers/prompt_canary/dashboard/prompts_controller.rb
31
+ - app/views/layouts/prompt_canary/application.html.erb
32
+ - app/views/prompt_canary/dashboard/prompts/index.html.erb
33
+ - app/views/prompt_canary/dashboard/prompts/show.html.erb
34
+ - config/routes.rb
35
+ - examples/auto_rollback.rb
36
+ - examples/demo.rb
37
+ - exe/prompt_canary
38
+ - lib/generators/prompt_canary/install_generator.rb
39
+ - lib/generators/prompt_canary/templates/create_prompt_canary_calls.rb
40
+ - lib/prompt_canary.rb
41
+ - lib/prompt_canary/adapter_factory.rb
42
+ - lib/prompt_canary/adapters/anthropic.rb
43
+ - lib/prompt_canary/adapters/base.rb
44
+ - lib/prompt_canary/cli.rb
45
+ - lib/prompt_canary/cli/commands/history.rb
46
+ - lib/prompt_canary/cli/commands/status.rb
47
+ - lib/prompt_canary/configuration.rb
48
+ - lib/prompt_canary/deployment.rb
49
+ - lib/prompt_canary/engine.rb
50
+ - lib/prompt_canary/monitor.rb
51
+ - lib/prompt_canary/monitor_job.rb
52
+ - lib/prompt_canary/prompt.rb
53
+ - lib/prompt_canary/prompt_executor.rb
54
+ - lib/prompt_canary/promptable.rb
55
+ - lib/prompt_canary/railtie.rb
56
+ - lib/prompt_canary/recorder.rb
57
+ - lib/prompt_canary/result.rb
58
+ - lib/prompt_canary/rollback_rule.rb
59
+ - lib/prompt_canary/router.rb
60
+ - lib/prompt_canary/storage/active_record_adapter.rb
61
+ - lib/prompt_canary/storage/memory.rb
62
+ - lib/prompt_canary/storage/sqlite.rb
63
+ - lib/prompt_canary/storage_factory.rb
64
+ - lib/prompt_canary/version.rb
65
+ - lib/prompt_canary/version_builder.rb
66
+ - lib/prompt_canary/version_object.rb
67
+ - sig/prompt_canary.rbs
68
+ homepage: https://github.com/rockwellwindsor/prompt_canary
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ allowed_push_host: https://rubygems.org
73
+ homepage_uri: https://github.com/rockwellwindsor/prompt_canary
74
+ source_code_uri: https://github.com/rockwellwindsor/prompt_canary
75
+ changelog_uri: https://github.com/rockwellwindsor/prompt_canary/blob/main/CHANGELOG.md
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.2.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.4.1
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: Canary deploys and automatic rollback for LLM prompts in Ruby.
95
+ test_files: []