Share
Embed
Writing DSL for DSL: Catch Code as It’s Born with TracePoint
Let’s dive into an unconventional use of Ruby’s TracePoint API, not for performance profiling, but for runtime introspection in metaprogramming-heavy Ruby code.
We’ll focus on real-world use case of writing DSLs for use with existing DSLs, exploring how TracePoint enables us to untangle messy results of metaprogramming techniques and do things like dynamic linking logic to DSL-generated methods.
Expect a blend of advanced Ruby techniques, practical insights, and a fresh perspective on a lesser-known Ruby API.
Andrey Novikov
April 24, 2026
Transcript
30 slides
-
Writing DSL for DSL Catch Code as It’s Born with
the TracePoint API Andrey Novikov, Evil Martians RubyKaigi 2026 April 24, 2026 -
About me Hi, I’m Andrey Back-end engineer at Evil Martians
Writing Ruby, Go, and whatever Love open-source software Living in Japan Love to ride mopeds, motorcycles, and cars all over Japan -
Martian Open Source Yabeda: Ruby application instrumentation framework Lefthook: git
hooks manager AnyCable: Polyglot replacement for ActionCable server PostCS S : A tool for transforming CS S with JavaS cript Imgproxy: Fast and secure standalone server for Logux: Client-server communication framework based on Overmind: Process manager for Procfile-based And many others at evilmartians.com/oss resizing and converting remote images Optimistic UI, CRDT, and log applications and tmux -
Typelizer Extracts data type information from serializers and models in
your app Supports Alba, Alba, ActiveModel::Serializer, Oj::Serializer, Panko::Serializer Generates TypeScript types or OpenAPI schemas from it github.com/skryukov/typelizer -
One more martian Svyatoslav Kryukov (or Svyat) Back-end engineer at
Evil Martians Author of the typelizer gem My virtual co-presenter today! -
Typelizer class PostResource < ApplicationResource include Typelizer::DSL attributes :id, :title,
:body typelize :number attribute :rating { 42 } end Database Table Columns ActiveRecord Attributes Serializer Custom Fields rails typelizer:generate Post: type Post = { type: object id: number; properties: title: string; id: { type: number } body: string; title: { type: string } rating: number; body: { type: string } }; rating: { type: number } Typelizer TypeScript Typings OpenAPI Schema -
Typelizer’s challenge Serializers provide their own DSL to define fields
Typelizer need to annotate these methods with its own DSL To allow re-defining types -
DSL-for-DSL Desired API is to annotate the serializer fields with
type information: class PostResource < ApplicationResource attributes :id, :title, :body typelize :number attribute :rating { 42 } end This is second order DSL 🫣 -
DSL-for-DSL challenge When you create a DSL that annotates a
method, it works because def returns the method name: memoize def foo = computation memoize will get :foo and will know what to memoize. -
DSL-for-DSL challenge But we can’t expect that someone’s DSL method
will return something useful to us, like API field name… irb(main):001:0> attribute :foo { 42 } # => ¯\_(ツ)_/¯ Even if it does accepting return value of a method call may not look nice in the code: typelize :number, comment: "something", \ attribute :foo { 42 } -
DSL-for-DSL challenge We can duplicate the method name: typelize foo:
:number attribute :foo { 42 } But it is ugly! We want to specify just type: typelize :number attribute :foo { 42 } -
DSL-for-DSL challenge Of course we can just use prepend :
module Typelizer::DSL::Hooks::Alba def attribute(name, *args, **kwargs, &block) Typelizer.consume_keyless_type(name) super(name, *args, **kwargs, &block) end def has_many(name, *args, **kwargs) Typelizer.consume_keyless_type(name) Typelizer.record_multi(name) super(name, *args, **kwargs, &block) end # … repeat for every DSL method in the serializer library we want to hook end -
Svyat wants to find a better approach That’s a lot
of mundane repitition! If only there were a way to catch things without monkey-patching specific methods, so that anything newly added to serializers would just work… -
So we need a way to subscribe to events in
Ruby runtime… TracePoint API (yes, again!) -
TracePoint API Allows subscribe to runtime events in the Typical
use cases: code: Profiling / tracing slow code paths method calls / returns Coverage class / module definitions See day 1 talk from @anmarchenko exceptions Debuggers every line execution, etc. And get access to the context of the event: class instance, local variables, etc. TracePoint API Docs -
Quick anatomy trace = TracePoint.new(:call) do |tp| p [tp.event, tp.method_id,
...] end trace.enable # ... run something ... trace.disable tp.event : what happened tp.self : receiver tp.binding : local vars / self tp.defined_class : where method lives tp.path , tp.lineno : source location (when available) TracePoint API Docs -
The DSL-on-DSL challenge You want to write a DSL that
integrates with someone else’s DSL: the “inner” DSL does something dynamically when user calls its methods our DSL needs to attach logic to those calls these methods are usually class or module methods, so they are not called on application runtime but rather on application load and those methods haven’t been called yet when our typelize method is called -
The trick Trace method calls during require of a serializer
file Enable TracePoint only while loading target files: trace.enable require file trace.disable Make a special command that will load only serializers before they were loaded by the application autoloading. Watch for calls that happen on “typelized” classes: is this receiver relevant? is the called method “interesting”? if yes: extract context from binding -
Real code files.each do |file| trace = TracePoint.new(:call) do |tp|
next unless typelized_class?(tp.self) serializer_plugin = build_scan_plugin_for(tp.self) next unless serializer_plugin if tp.callee_id.in?(serializer_plugin.methods_to_typelize) type, attrs = tp.self.keyless_type name = tp.binding.local_variable_get(:name) if tp.binding.local_variable_defined?(:name) tp.self.typelize(**serializer_plugin.typelize_method_transform(method: tp.callee_id, binding: tp.bindi tp.self.keyless_type = nil end end trace.enable require file trace.disable end Source: typelizer v0.6.0 generator.rb generator.rb:45-66 -
Real code (fragment) type, attrs = tp.self.keyless_type # Saved from
`typelize` method call if tp.binding.local_variable_defined?(:name) name = tp.binding.local_variable_get(:name) tp.self.typelize(name => [type, **attrs]) if name end tp.self.keyless_type = nil -
DSL with TracePoint API: pros and cons Pros More flexible
Fun! Cons There is a performance overhead Harder to understand Need to rely on internal API of the target DSL A lot of logic in the handler TracePoint performance o… -
The main problem if tp.callee_id.in?(serializer_plugin.methods_to_typelize) We still need to keep
track of all underlying methods Some of them have special logic E.g. has_many and belongs_to both are just aliases to resource in Alba, but has_many returns array of records, so we need to account for that. -
Disappointment The idea was that with TracePoint, you don’t have
to touch specific methods — everything added to the libraries would work automatically. But in reality, sometimes the logic is hidden in the name itself! So if you have to keep record of all methods to build on anyway—then screw it, let’s just use prepend! Also it turned out to be faster and easier to understand! -
Typelizer goes to prepend module Typelizer::DSL::Hooks::Alba def attribute(name, *args, **kwargs,
&block) Typelizer.consume_keyless_type(name) super end def has_many(name, *args, **kwargs) Typelizer.consume_keyless_type(name) Typelizer.record_multi(name) super end # … repeat for every DSL method in the serializer library we want to hook end Typelizer goes to prepend -
But should you use TracePoint API to build a DSL?
Probably not! -
Tips for brave and courageous Rules of thumb: subscribe to
one event when possible Most probably it will be :call only filter early ( next unless ... ) Handler will be called a lot, reduce performance overhead as much as possible enable TracePoint for a short time window Like only during require of a file, avoid enabling it in application runtime -
Thank you! @Envek @Envek Bye! github.com/Envek @skryukov @skryukov_dev github.com/skryu… @evilmartians
@evilmartians 🌏 evilmartians.com evilmartians.com Our awesome blog: evilmartians.com/chronicles !