Skip to content

Commit 078c43e

Browse files
committed
ams and ahoy integrations, site updates
1 parent 2b8125a commit 078c43e

24 files changed

Lines changed: 7306 additions & 52 deletions

Gemfile.lock

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ GEM
5454
erubi (~> 1.11)
5555
rails-dom-testing (~> 2.2)
5656
rails-html-sanitizer (~> 1.6)
57+
active_model_serializers (0.10.15)
58+
actionpack (>= 4.1)
59+
activemodel (>= 4.1)
60+
case_transform (>= 0.2)
61+
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
5762
activejob (7.2.2.1)
5863
activesupport (= 7.2.2.1)
5964
globalid (>= 0.3.6)
@@ -83,6 +88,10 @@ GEM
8388
tzinfo (~> 2.0, >= 2.0.5)
8489
addressable (2.8.7)
8590
public_suffix (>= 2.0.2, < 7.0)
91+
ahoy_matey (5.4.0)
92+
activesupport (>= 7.1)
93+
device_detector (>= 1)
94+
safely_block (>= 0.4)
8695
amazing_print (1.7.2)
8796
ansi (1.5.0)
8897
ast (2.4.2)
@@ -100,6 +109,8 @@ GEM
100109
image_processing (~> 1.1)
101110
marcel (~> 1.0.0)
102111
ssrf_filter (~> 1.0)
112+
case_transform (0.2)
113+
activesupport
103114
climate_control (1.2.0)
104115
concurrent-ruby (1.3.4)
105116
connection_pool (2.5.0)
@@ -110,6 +121,7 @@ GEM
110121
debug (1.10.0)
111122
irb (~> 1.10)
112123
reline (>= 0.3.8)
124+
device_detector (1.1.3)
113125
diff-lcs (1.6.0)
114126
docile (1.4.1)
115127
down (5.4.2)
@@ -139,6 +151,7 @@ GEM
139151
reline (>= 0.4.2)
140152
jaro_winkler (1.6.0)
141153
json (2.10.1)
154+
jsonapi-renderer (0.2.2)
142155
kramdown (2.5.1)
143156
rexml (>= 3.3.9)
144157
kramdown-parser-gfm (1.1.0)
@@ -298,6 +311,7 @@ GEM
298311
ruby-vips (2.2.3)
299312
ffi (~> 1.12)
300313
logger
314+
safely_block (0.5.0)
301315
securerandom (0.4.1)
302316
semantic_logger (4.17.0)
303317
concurrent-ruby (~> 1.0)
@@ -413,6 +427,8 @@ PLATFORMS
413427
x86_64-linux-musl
414428

415429
DEPENDENCIES
430+
active_model_serializers (~> 0.10.13)
431+
ahoy_matey (~> 5.2)
416432
amazing_print
417433
bigdecimal
418434
bugsnag (~> 6.26)

INTEGRATIONS.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
**Purpose**
2+
3+
- Document how to add a new first‑class integration to LogStruct so it’s consistent, type‑safe, well‑tested, and shows up automatically in the docs site with an auto‑generated example log.
4+
5+
**High‑Level Flow**
6+
7+
- Add a typed log structure under `lib/log_struct/log/` (so the docs generator picks it up).
8+
- Add a configuration toggle in `ConfigStruct::Integrations` and wire it into `Integrations.setup_integrations`.
9+
- Implement the integration under `lib/log_struct/integrations/…` to produce that log type.
10+
- Add the dev dependency for the third‑party gem and generate RBIs with Tapioca.
11+
- Add tests (unit + behavior) under `test/log_struct/integrations` and (if needed) `test/log_struct/log`.
12+
- Add a short entry in the docs metadata (`site/lib/integration-helpers.ts`). The Integrations page will render it automatically via `AllLogTypes`.
13+
- Run type export to update the docs’ TypeScript types.
14+
15+
**Conventions**
16+
17+
- Files and naming:
18+
- Log type: `lib/log_struct/log/<Name>.rb` (class `LogStruct::Log::<Name>`)
19+
- Integration: `lib/log_struct/integrations/<name>.rb` (module `LogStruct::Integrations::<Name>`)
20+
- Optional submodules (e.g., `logger.rb`, `log_subscriber.rb`) live below a folder `lib/log_struct/integrations/<name>/`.
21+
- Tests mirror structure under `test/log_struct/log` and `test/log_struct/integrations`.
22+
- Type safety:
23+
- Every Ruby file must be `# typed: strict` and use `T::Sig`.
24+
- For integration setup signatures: `sig { params(config: LogStruct::Configuration).returns(T.nilable(TrueClass)) }`.
25+
- Prefer real constants (the gem is a development dependency), but guard runtime with `defined?(::GemModule)` to be safe in user apps.
26+
- Error handling:
27+
- Never raise from the integration path; use `LogStruct.handle_exception(error, source: <Source>, context: {integration: :name})`.
28+
- Logging:
29+
- Produce a dedicated typed log struct (do NOT emit a generic/plain log).
30+
- Message naming: concise and stable, e.g., `"ams.render"`, `"ahoy.track"`.
31+
- Choose the correct `Source` (Rails/App/Job/Storage/…) and a suitable `Event` (`Event::Log` unless a more specific event applies).
32+
33+
**Step‑by‑Step Checklist**
34+
35+
1. Create the typed log struct
36+
- File: `lib/log_struct/log/<name>.rb`
37+
- Include: `Interfaces::CommonFields`, `Interfaces::AdditionalDataField`, `SerializeCommon`, `MergeAdditionalDataFields`.
38+
- Define required fields and defaults (e.g., `message`, specific properties for the integration), and implement `serialize` to add them to the output.
39+
- Example skeleton:
40+
41+
class LogStruct::Log::Example < T::Struct
42+
extend T::Sig
43+
include Interfaces::CommonFields
44+
include Interfaces::AdditionalDataField
45+
include SerializeCommon
46+
include MergeAdditionalDataFields
47+
48+
ExampleEvent = T.type_alias { Event::Log }
49+
const :source, Source::Rails, default: T.let(Source::Rails, Source::Rails)
50+
const :event, ExampleEvent, default: T.let(Event::Log, ExampleEvent)
51+
const :level, Level, default: T.let(Level::Info, Level)
52+
const :timestamp, Time, factory: -> { Time.now }
53+
54+
const :message, String, default: "example.event"
55+
const :detail, T.nilable(String), default: nil
56+
const :additional_data, T::Hash[Symbol, T.untyped], default: {}
57+
58+
sig { override.params(strict: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
59+
def serialize(strict = true)
60+
h = serialize_common(strict)
61+
merge_additional_data_fields(h)
62+
h[LOG_KEYS.fetch(:message)] = message
63+
h[:detail] = detail if detail
64+
h
65+
end
66+
end
67+
68+
2. Register the log type
69+
- In `lib/log_struct/log.rb`:
70+
- `require_relative "log/<name>"`
71+
- Add `T.class_of(LogStruct::Log::<Name>)` to `LogClassType`.
72+
73+
3. Add a config toggle
74+
- In `lib/log_struct/config_struct/integrations.rb`:
75+
- `prop :enable_<name>, T::Boolean, default: true`
76+
- In `lib/log_struct/integrations.rb`:
77+
- `require_relative "integrations/<name>"`
78+
- Call `Integrations::<Name>.setup(config)` inside `setup_integrations` behind the toggle.
79+
80+
4. Implement the integration
81+
- File: `lib/log_struct/integrations/<name>.rb`
82+
- Guard on presence of the third‑party gem (`return nil unless defined?(::ThirdParty)`), subscribe/hook into events, build your typed log, and log it with `LogStruct.info(log)`.
83+
- Use `handle_exception` on rescue.
84+
85+
5. Add development dependency + RBIs
86+
- In `logstruct.gemspec`, add the gem as a development dependency with a supported version:
87+
- `spec.add_development_dependency "third_party_gem", "~> X.Y"`
88+
- Run:
89+
- `bundle install`
90+
- `bundle exec tapioca gems` (commits RBIs under `sorbet/rbi/gems/...`).
91+
92+
6. Tests
93+
- Unit/behavior tests under `test/log_struct/integrations/<name>_test.rb`.
94+
- If the integration exposes a new log struct surface, add a focused test in `test/log_struct/log/<name>_test.rb` when it adds non‑trivial serialization.
95+
- Prefer real gem objects with small fakes only where necessary; the gem is available as a dev dependency.
96+
- Assertions should inspect `as_json` and verify:
97+
- `src`, `evt`, `lvl`, `ts` exist
98+
- `msg` matches the agreed name
99+
- Integration‑specific fields are present and correct
100+
101+
7. Docs
102+
- No custom sections in the Integrations page.
103+
- The Integrations page lists all `AllLogTypes` with titles/descriptions from `site/lib/integration-helpers.ts`.
104+
- Add an entry to `getLogTypeInfo` for your new log type (title, concise description, optional `configuration_code: 'integrations_configuration'`).
105+
- Run the type export to regenerate the docs’ TypeScript assets so example logs render:
106+
- `ruby scripts/export_typescript_types.rb`
107+
108+
8. Sorbet + CI
109+
- Run `scripts/typecheck.sh` (must be clean).
110+
- Run `scripts/test.rb` and `scripts/rails_tests.sh` (must be green).
111+
- CI will enforce Tapioca verification and coverage threshold (80%+ merged).
112+
113+
**Patterns to Follow (Examples)**
114+
115+
- GoodJob:
116+
- Log type: `lib/log_struct/log/good_job.rb`
117+
- Integration: `lib/log_struct/integrations/good_job.rb` (+ `logger.rb`, `log_subscriber.rb`)
118+
- Toggle: `enable_goodjob`
119+
- SQL (ActiveRecord):
120+
- Log type: `lib/log_struct/log/sql.rb`
121+
- Integration: `lib/log_struct/integrations/active_record.rb`
122+
- Toggle: `enable_sql_logging`
123+
- ActiveModelSerializers:
124+
- Log type: `lib/log_struct/log/active_model_serializers.rb` (msg: "ams.render")
125+
- Integration: `lib/log_struct/integrations/active_model_serializers.rb`
126+
- Toggle: `enable_active_model_serializers`
127+
- Ahoy:
128+
- Log type: `lib/log_struct/log/ahoy.rb` (msg: "ahoy.track")
129+
- Integration: `lib/log_struct/integrations/ahoy.rb`
130+
- Toggle: `enable_ahoy`
131+
132+
**Gotchas**
133+
134+
- Don’t emit generic/plain logs for integrations that deserve a first‑class type.
135+
- Keep message names short and stable; avoid dumping raw payloads into `msg`.
136+
- Don’t raise in integration hooks; always call `handle_exception` with the correct `Source`.
137+
- Docs auto‑generation depends on the log type being included in `LogClassType` and the TypeScript export.
138+
139+
**One‑Time Commands (after adding or changing log types)**
140+
141+
- `bundle install`
142+
- `bundle exec tapioca gems`
143+
- `ruby scripts/export_typescript_types.rb`
144+
- `scripts/typecheck.sh`
145+
- `scripts/test.rb` and (optionally) `scripts/rails_tests.sh`

lib/log_struct/config_struct/integrations.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ class Integrations < T::Struct
8484
# Include bind parameters in SQL logs (disable in production for security)
8585
# Default: true in development/test, false in production
8686
prop :sql_log_bind_params, T::Boolean, factory: -> { !defined?(::Rails) || !::Rails.respond_to?(:env) || !::Rails.env.production? }
87+
88+
# Enable Ahoy (analytics events) integration
89+
# Default: true (safe no-op unless Ahoy is defined)
90+
prop :enable_ahoy, T::Boolean, default: true
91+
92+
# Enable ActiveModelSerializers integration
93+
# Default: true (safe no-op unless ActiveModelSerializers is defined)
94+
prop :enable_active_model_serializers, T::Boolean, default: true
8795
end
8896
end
8997
end

lib/log_struct/integrations.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
require_relative "integrations/active_storage"
1515
require_relative "integrations/carrierwave"
1616
require_relative "integrations/sorbet"
17+
require_relative "integrations/ahoy"
18+
require_relative "integrations/active_model_serializers"
1719

1820
module LogStruct
1921
module Integrations
@@ -30,6 +32,8 @@ def self.setup_integrations
3032
Integrations::ActiveRecord.setup(config) if config.integrations.enable_sql_logging
3133
Integrations::Sidekiq.setup(config) if config.integrations.enable_sidekiq
3234
Integrations::GoodJob.setup(config) if config.integrations.enable_goodjob
35+
Integrations::Ahoy.setup(config) if config.integrations.enable_ahoy
36+
Integrations::ActiveModelSerializers.setup(config) if config.integrations.enable_active_model_serializers
3337
Integrations::HostAuthorization.setup(config) if config.integrations.enable_host_authorization
3438
Integrations::RackErrorHandler.setup(config) if config.integrations.enable_rack_error_handler
3539
Integrations::Shrine.setup(config) if config.integrations.enable_shrine
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "active_support/notifications"
5+
6+
module LogStruct
7+
module Integrations
8+
# ActiveModelSerializers integration. Subscribes to AMS notifications and
9+
# emits structured logs with serializer/adapter/duration details.
10+
module ActiveModelSerializers
11+
extend T::Sig
12+
13+
sig { params(config: LogStruct::Configuration).returns(T.nilable(TrueClass)) }
14+
def self.setup(config)
15+
return nil unless defined?(::ActiveSupport::Notifications)
16+
17+
# Only activate if AMS appears to be present
18+
return nil unless defined?(::ActiveModelSerializers)
19+
20+
# Subscribe to common AMS notification names; keep broad but specific
21+
pattern = /\.active_model_serializers\z/
22+
23+
::ActiveSupport::Notifications.subscribe(pattern) do |_name, started, finished, _unique_id, payload|
24+
duration_ms = ((finished - started) * 1000.0)
25+
26+
data = {
27+
duration_ms: duration_ms
28+
}
29+
30+
serializer = payload[:serializer] || payload[:serializer_class]
31+
adapter = payload[:adapter]
32+
resource = payload[:resource] || payload[:object]
33+
34+
data[:serializer] = serializer.to_s if serializer
35+
data[:adapter] = adapter.to_s if adapter
36+
data[:resource_class] = resource.class.name if resource
37+
38+
LogStruct.info(
39+
LogStruct::Log::ActiveModelSerializers.new(
40+
serializer: data[:serializer]&.to_s,
41+
adapter: data[:adapter]&.to_s,
42+
resource_class: data[:resource_class]&.to_s,
43+
duration_ms: T.cast(data[:duration_ms], T.nilable(Float)),
44+
additional_data: {}
45+
)
46+
)
47+
rescue => e
48+
LogStruct.handle_exception(e, source: LogStruct::Source::Rails, context: {integration: :active_model_serializers})
49+
end
50+
51+
true
52+
end
53+
end
54+
end
55+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module LogStruct
5+
module Integrations
6+
# Ahoy analytics integration. If Ahoy is present, prepend a small hook to
7+
# Ahoy::Tracker#track to emit a structured log for analytics events.
8+
module Ahoy
9+
extend T::Sig
10+
11+
sig { params(config: LogStruct::Configuration).returns(T.nilable(TrueClass)) }
12+
def self.setup(config)
13+
return nil unless defined?(::Ahoy)
14+
15+
if defined?(::Ahoy::Tracker)
16+
mod = Module.new do
17+
extend T::Sig
18+
19+
sig { params(name: T.untyped, properties: T.nilable(T::Hash[T.untyped, T.untyped]), options: T.untyped).returns(T.untyped) }
20+
def track(name, properties = nil, options = nil)
21+
result = super
22+
begin
23+
# Emit a lightweight structured log about the analytics event
24+
data = {
25+
ahoy_event: T.let(name, T.untyped)
26+
}
27+
data[:properties] = properties if properties
28+
LogStruct.info(
29+
LogStruct::Log::Ahoy.new(
30+
ahoy_event: T.let(name, T.nilable(String)),
31+
properties: T.let(
32+
properties && properties.transform_keys { |k| k.to_sym },
33+
T.nilable(T::Hash[Symbol, T.untyped])
34+
),
35+
additional_data: {}
36+
)
37+
)
38+
rescue => e
39+
# Never raise from logging; rely on global error handling policies
40+
LogStruct.handle_exception(e, source: LogStruct::Source::App, context: {integration: :ahoy})
41+
end
42+
result
43+
end
44+
end
45+
46+
T.unsafe(::Ahoy::Tracker).prepend(mod)
47+
end
48+
49+
true
50+
end
51+
end
52+
end
53+
end

lib/log_struct/log.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
require_relative "log/shrine"
2020
require_relative "log/sidekiq"
2121
require_relative "log/sql"
22+
require_relative "log/ahoy"
23+
require_relative "log/active_model_serializers"
2224

2325
module LogStruct
2426
# Type aliases for all possible log types
@@ -37,7 +39,9 @@ module LogStruct
3739
T.class_of(LogStruct::Log::Security),
3840
T.class_of(LogStruct::Log::Shrine),
3941
T.class_of(LogStruct::Log::Sidekiq),
40-
T.class_of(LogStruct::Log::SQL)
42+
T.class_of(LogStruct::Log::SQL),
43+
T.class_of(LogStruct::Log::Ahoy),
44+
T.class_of(LogStruct::Log::ActiveModelSerializers)
4145
)
4246
end
4347
end

0 commit comments

Comments
 (0)