Class: MetaEvents::Tracker
- Inherits:
-
Object
- Object
- MetaEvents::Tracker
- Defined in:
- lib/meta_events/tracker.rb
Overview
The MetaEvents::Tracker is the primary (and only) class you ordinarily use from the MetaEvents system. By itself, it does not actually call any event-tracking services; it takes the events you give it, expands them into fully-qualified event names, expands nested properties, validates it all against the DSL, and then calls through to one or more event receivers, which are simply any object that responds to a very simple method signature.
## Instantiation and Lifecycle
A MetaEvents::Tracker object is designed to be created once in each context where you’re processing actions on behalf of a particular user – for example, once in the request cycle in a Rails application, most likely as part of ApplicationController. This is because a Tracker accepts, in its constructor, a distinct_id
, which is the way you identify a particular user to your events system. It is possible to override this on an event-by-event basis (by passing a :distinct_id
property explicitly to the event), but it is generally cleaner and easier to simply instantiate the MetaEvents::Tracker object once for each processing cycle.
Further, a Tracker accepts _implicit properties_ on creation; this is a set of zero or more properties that get automatically added to every event processed by the tracker. Typically, these will be user-centric properties, like the user’s location, age, plan, or anything else. By using the support for #to_event_properties (below), the canonical form of Tracker instantiation looks something like:
event_tracker = MetaEvents::Tracker.new(current_user.id, request.remote_ip,
:implicit_properties => { :user => current_user })
…which will automatically add all properties exposed by User#to_event_properties on every single event fired by that Tracker.
See the discussion for #initialize, too – there are certain things you want to do for logged-out users and at the point when a user signs up.
If you concurrently are firing events from multiple versions in the MetaEvents DSL, you’ll need to use multiple MetaEvents::Tracker instances – any given Tracker only works with a single version at once. Since the point of DSL versions is to support wholesale overhauls of your entire events system, this is probably fine; the set of implicit properties you want to use will almost certainly have changed, too.
Any way you choose to use an MetaEvents::Tracker is fine – the overhead to creating one is pretty small.
## Event Receivers
To make an MetaEvents::Tracker actually do something, it must have one or more _event receiver_s. An event receiver is any object that responds to the following method:
track(distinct_id, event_name, event_properties)
…where distinct_id
is a String or Integer that uniquely identifies the user for which we’re firing the event, event_name
is a String which is the full name of the event (more on that below), and event_properties
is a map of String keys (the names of properties) to values that are numbers (any Numeric – integer or floating-point – will do), true, false, nil, a Time, or a String. This interface is designed to be extremely simple, and is modeled after the popular Mixpanel (www.mixpanel.com/) API.
IMPORTANT: Event receivers are called sequentially, in a loop, directly inside the call to #event!. If they raise an exception, it will be propagated through and will be received by the caller of #event!; if they are slow or time out, this latency will be directly experienced by the caller to #event!. This is intentional, because only you can know whether you want to swallow these exceptions or propagate them, or whether you need to make event reporting asynchronous – and, if so, how – or not. Think carefully, and add asychronicity or exception handling if needed.
Provided with this library is MetaEvents::TestReceiver, which will accept an IO object (like STDOUT), a Logger, or a block, and will accept events and write them as human-readable strings to this destination. Also, the ‘mixpanel-ruby’ gem is plug-compatible with this library – an instance of Mixpanel::Tracker is a valid event receiver.
To specify the event receiver(s), you can (in order of popularity):
-
Configure the default receiver(s) for all MetaEvents::Tracker instances that are not otherwise specified by using <tt>MetaEvents::Tracker.default_event_receivers = [ receiver1, receiver2 ]
-
Specify receivers at the time you create a new MetaEvents::Tracker: <tt>tracker = MetaEvents::Tracker.new(current_user.id, request.remote_ip, :event_receivers => [ receiver1, receiver2 ])
-
Modify an existing MetaEvents::Tracker: <tt>my_tracker.event_receivers = [ receiver1, receiver2 ]
## Version Specification
As mentioned above, any given MetaEvents::Tracker can only fire events from a single version within the MetaEvents DSL. Since the point of DSL versions is to support wholesale overhauls of your entire events system, this is probably fine; the set of implicit properties you want to use will almost certainly have changed, too.
To specify the version within your MetaEvents DSL that a Tracker will work against, you can:
-
Set the default for all MetaEvents::Tracker instances using
MetaEvents::Tracker.default_version = 1
; or -
Specify the version at the time you create a new MetaEvents::Tracker:
tracker = MetaEvents::Tracker.new(current_user.id, request.remote_ip, :version => 1)
;
MetaEvents::Tracker.default_version
is 1 by default, so, until you define your second version, you can safely ignore this.
## Setting Up Definitions
Part of the whole point of the MetaEvents::Tracker is that it works against the MetaEvents DSL. If you’re using this with Rails, you simply need to create config/events.rb
with something like:
global_events_prefix :pz
version 1, '2014-01-30' do
category :user do
event :signup, '2014-02-01', 'a user first creates their account'
event :login, '2014-02-01', 'a user enters their password'
end
end
…and it will “just work”.
If you’re not using Rails or you don’t want to do this, it’s still easy enough. You can specify a set of events in one of two ways:
-
As a separate file, using the MetaEvents DSL, just like the
config/events.rb
example above; -
Directly as an instance of MetaEvents::Definition::DefinitionSet, using any mechanism you choose.
Once you have either of the above, you can set up your MetaEvents::Tracker with it in any of these ways:
-
MetaEvents::Tracker.default_definitions = "path/to/myfile"
; -
MetaEvents::Tracker.default_definitions = my_definition_set
– both of these will set the definitions for any and all MetaEvents::Tracker instances that do not have definitions directly set on them; -
my_tracker = MetaEvents::Tracker.new(current_user.id, request.remote_ip, :definitions => "path/to/myfile")
; -
my_tracker = MetaEvents::Tracker.new(current_user.id, request.remote_ip, :definitions => my_definition_set)
– setting it in the constructor.
## Implicit Properties
When you create an MetaEvents::Tracker instance, you can add implicit properties to it simply by passing the :implicit_properties
option to the constructor. These properties will be automatically attached to all events fired by that object, unless they are explicitly overridden with a different value (nil
will work if needed) passed in the individual event call.
## Property Merging: Sub-Hashes
Sometimes you have large numbers of properties that pertain to a particular entity in your system. For this reason, the MetaEvents::Tracker supports sub-hashes:
my_tracker.event!(:user, :signed_up,
:user => { :first_name => 'Jane', :last_name => 'Dunham', :city => 'Seattle' }, :color => 'green')
This will result in a call to the event receivers that looks like this:
receiver.track('some_distinct_id', 'ab1_user_signed_up', {
'user_first_name' => 'Jane',
'user_last_name' => 'Dunham',
'user_city' => 'Seattle',
'color' => 'green'
})
Using this mechanism, you can easily sling around entire sets of properties without needing to write lots of code using Hash, #merge, and so on. Even better, if you accidentally collide two properties with each other this way (such as if you specified a separate, top-level :user_city
key above), MetaEvents::Tracker will let you know about it.
## Property Merging: to_event_properties
What you really want, however, is to be able to pass entire objects into an event – this is where the real power of the MetaEvents::Tracker comes in handy.
If you pass into an event, or into the implicit-properties set, a key that’s bound to a value that’s an object that responds to #to_event_properties, then this method will be called, and its properties merged in. For example, assume you have the following:
class User < ActiveRecord::Base
...
def to_event_properties
{
:age => ((Time.now - date_of_birth) / 1.year).floor,
:payment_level => payment_level,
:city => home_city
...
}
end
...
end
…and now you make a call like this:
my_tracker.event!(:user, :logged_in, :user => current_user, :last_login => current_user.last_login)
You’ll end up with a set of properties like this:
receiver.track('some_distinct_id', 'ab1_user_logged_in', {
'user_age' => 27,
'user_payment_level' => 'enterprise',
'user_city' => 'Seattle',
'last_login' => 2014-02-03 17:28:34 -0800
})
Using this mechanism, you can (and should!) define standard #to_event_properties methods on many of your models, and then pass in models frequently – this allows you to easily build large sets of properties to pass with your events, which is one of the keys to making many event-tracking tools as powerful as possible.
Because this mechanism works the way it does, you can also pass in multiple models of the same type:
my_tracker.event!(:user, :sent_message, :from => from_user, :to => to_user)
…becomes:
receiver.track('some_distinct_id', 'ab1_user_sent_message', {
'from_age' => 27,
'from_payment_level' => 'enterprise',
'from_city' => 'Seattle',
'to_age' => 35,
'to_payment_level' => 'personal',
'to_city' => 'San Francisco'
})
Note that if you need different #to_event_properties objects for different situations, as sometimes occurs, the fact that Hash merging works the same way means you can build it yourself, trivially:
my_tracker.event!(:user, :logged_in,
:user => current_user.login_event_properties, :last_login => current_user.last_login)
…or however you’d like it to work.
## The Global Events Prefix
No matter how you configure the MetaEvents::Tracker, you must specify a “global events prefix” – either using the MetaEvents DSL (global_events_prefix :foo
), or in the constructor (MetaEvents::Tracker.new(current_user.id, request.remote_ip, :global_events_prefix => :foo)
).
The point of the global events prefix is to help distinguish events generated by this system from any events you may have feeding into a target system that are generated elsewhere. You can set the global events prefix to anything you like; it, plus, the version number, will be prepended to all event names. For example, if you set it to ‘pz’, and you’re using version 3, then an event :foo
in a category :bar
will have the full name pz3_foo_bar
.
We recommend that you keep the global events prefix short, simply because tools like Mixpanel often have a relatively small amount of screen real estate available for event names.
### Overriding Event Names
There might be a situation where users performing analysis desire a friendlier name than the default. The external name can be customized with a lambda (or any object that responds to #call(event)
). To customize the external name for all MetaEvents::Tracker instances, specify MetaEvents::Tracker.default_external_name = lambda { |event| "custom event name" }
.
To customize the external name for a specific MetaEvents::Tracker instance, pass the lambda in the constructor, for example: MetaEvents::Tracker.new(current_user.id, request.remote_ip, :external_name => lambda {|e| "#{e.full_name}_CUSTOM" })
To reset default behavior back to the built-in default, simply set MetaEvents::Tracker.default_external_name = nil
The event passed to external_name is an instance of ::MetaEvents::Definition::Event
Defined Under Namespace
Classes: EventError, PropertyCollisionError
Constant Summary collapse
- DEFAULT_EXTERNAL_NAME =
The built-in default calculation of an external event name, which is the event’s
:full_name
lambda { |event| event.full_name }
- FLOAT_INFINITY =
(1.0 / 0.0)
Class Attribute Summary collapse
-
.default_definitions ⇒ Object
Returns the value of attribute default_definitions.
Instance Attribute Summary collapse
-
#definitions ⇒ Object
readonly
The ::MetaEvents::Definitions::DefinitionSet that this Tracker is using.
-
#distinct_id ⇒ Object
Returns the value of attribute distinct_id.
-
#event_receivers ⇒ Object
The set of event receivers that this MetaEvents::Tracker instance will use.
-
#external_name ⇒ Object
readonly
A method that provides the external name for an event.
-
#version ⇒ Object
readonly
The version of events that this Tracker is using.
Class Method Summary collapse
-
.default_external_name ⇒ Object
If a default external name provider was not specified, use the built-in default.
-
.default_external_name=(provider) ⇒ Object
The default value that new MetaEvents::Tracker instances will use to provide external names for events.
-
.merge_properties(target, source, separator, prefix = nil, depth = 0) ⇒ Object
Given a target Hash of properties in
target
, and a source Hash of properties insource
, merges all properties insource
intotarget
, obeying our hash-expansion rules (as specified in the introduction to this class). -
.normalize_scalar_property_value(value) ⇒ Object
Given a potential scalar value for a property, either returns the value that should actually be set in the resulting set of properties (for example, converting Symbols to Strings) or returns
:invalid_property_value
if that isn’t a valid scalar value for a property.
Instance Method Summary collapse
-
#effective_properties(category_name, event_name, additional_properties = { }) ⇒ Object
Given a category, an event, and (optionally) additional properties, performs all of the expansion and validation of #event!, but does not actually fire the event – rather, returns a Hash containing:.
-
#event!(category_name, event_name, additional_properties = { }) ⇒ Object
Fires an event.
-
#initialize(distinct_id, ip, options = { }) ⇒ Tracker
constructor
Creates a new instance.
Constructor Details
#initialize(distinct_id, ip, options = { }) ⇒ Tracker
Creates a new instance.
distinct_id
is the “distinct ID” of the user on behalf of whom events are going to be fired; this can be nil
if there is no such user (for example, if you’re firing events from a background job that has nothing to do with any particular user). This will be automatically added to all events fired from this MetaEvents::Tracker as a property named “distinct_id”. Typically, this will be the primary key of your users
table, although it can be any unique identifier you want.
ip
is the IP address of the user. This is called out as an explicit parameter so that you don’t forget it; you can pass nil
if you need to or if it isn’t relevant, but you generally should pass it – systems like Mixpanel use it to do geolocation for the client. If you need to override this on an event-by-event basis, simply pass a property named ip
.
(If a user has not logged in yet, you will probably want to assign them a unique ID anyway, via a cookie, and then pass this ID here. If the user logs in to an already-existing account, you probably just want to switch to using their logged-in user ID, since the stuff they did before they logged in isn’t very interesting – you already have them as a user. But if they sign up for a new account, you’ll lose tracking across that boundary unless your events provider provides something like Mixpanel’s alias
call; making that kind of call is beyond the scope of MetaEvents, and should be done separately.)
options
can contain:
- :definitions
-
If present, this can be anything accepted by ::MetaEvents::Definition::DefinitionSet#from, which will currently accept a pathname to a file, an
IO
object that contains the text of definitions, or an ::MetaEvents::Definition::DefinitionSet that you create however you want. If you don’t pass:definitions
, then this will use whatever the class property:default_event_receivers
is set to. (If neither one of these is set, you will receive an ArgumentError.) - :version
-
If present, this should be an integer that specifies which version within the specified MetaEvents DSL this MetaEvents::Tracker should fire events from. A single Tracker can only fire events from one version; if you need to support multiple versions simultaneously (for example, if you want to have a period of overlap during the transition from one version of your events system to another), create multiple Trackers.
- :event_receivers
-
If present, this should be a (possibly empty) Array that lists the set of event-receiver objects that you want fired events delivered to.
- :implicit_properties
-
If present, this should be a Hash; this defines a set of properties that will get included with every event fired from this Tracker. This can use the hash-merge and object syntax (#to_event_properties) documented above. Any properties explicitly passed with an event that have the same name as these properties will override these properties for that event.
- :external_name
-
If present, this should be a lambda that takes a single argument and returns a string, or an object that responds to call(event). If
:external_name
is not provided, it will use the default configured for the MetaEvents::Tracker class.
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 |
# File 'lib/meta_events/tracker.rb', line 339 def initialize(distinct_id, ip, = { }) .assert_valid_keys(:definitions, :version, :external_name, :implicit_properties, :event_receivers) definitions = [:definitions] || self.class.default_definitions unless definitions raise ArgumentError, "We have no event definitions to use. You must either set event definitions for " + "all event trackers using #{self.class.name}.default_definitions = (DefinitionSet or file), " + "or pass them to this constructor using :definitions." + "If you're using Rails, you can also simply put your definitions in the file " + "config/meta_events.rb, and they will be automatically loaded." end @definitions = ::MetaEvents::Definition::DefinitionSet.from(definitions) @version = [:version] || self.class.default_version || raise(ArgumentError, "Must specify a :version") @external_name = [:external_name] || self.class.default_external_name || raise(ArgumentError, "Must specify an :external_name") raise ArgumentError, ":external_name option must respond to #call" unless @external_name.respond_to?(:call) @implicit_properties = { } self.class.merge_properties(@implicit_properties, { :ip => normalize_ip(ip).to_s }, property_separator) if ip self.class.merge_properties(@implicit_properties, [:implicit_properties] || { }, property_separator) self.distinct_id = distinct_id if distinct_id self.event_receivers = Array([:event_receivers] || self.class.default_event_receivers.dup) end |
Class Attribute Details
.default_definitions ⇒ Object
Returns the value of attribute default_definitions.
261 262 263 |
# File 'lib/meta_events/tracker.rb', line 261 def default_definitions @default_definitions end |
Instance Attribute Details
#definitions ⇒ Object (readonly)
The ::MetaEvents::Definitions::DefinitionSet that this Tracker is using.
291 292 293 |
# File 'lib/meta_events/tracker.rb', line 291 def definitions @definitions end |
#distinct_id ⇒ Object
Returns the value of attribute distinct_id.
376 377 378 |
# File 'lib/meta_events/tracker.rb', line 376 def distinct_id @distinct_id end |
#event_receivers ⇒ Object
The set of event receivers that this MetaEvents::Tracker instance will use. This should always be an Array (although it can be empty if you don’t want to send events anywhere).
288 289 290 |
# File 'lib/meta_events/tracker.rb', line 288 def event_receivers @event_receivers end |
#external_name ⇒ Object (readonly)
A method that provides the external name for an event.
297 298 299 |
# File 'lib/meta_events/tracker.rb', line 297 def external_name @external_name end |
#version ⇒ Object (readonly)
The version of events that this Tracker is using.
294 295 296 |
# File 'lib/meta_events/tracker.rb', line 294 def version @version end |
Class Method Details
.default_external_name ⇒ Object
If a default external name provider was not specified, use the built-in default.
275 276 277 |
# File 'lib/meta_events/tracker.rb', line 275 def default_external_name @default_external_name || DEFAULT_EXTERNAL_NAME end |
.default_external_name=(provider) ⇒ Object
The default value that new MetaEvents::Tracker instances will use to provide external names for events.
267 268 269 270 271 272 |
# File 'lib/meta_events/tracker.rb', line 267 def default_external_name=(provider) if provider && !provider.respond_to?(:call) raise ArgumentError, "default_external_name must respond to #call" end @default_external_name = provider end |
.merge_properties(target, source, separator, prefix = nil, depth = 0) ⇒ Object
Given a target Hash of properties in target
, and a source Hash of properties in source
, merges all properties in source
into target
, obeying our hash-expansion rules (as specified in the introduction to this class). All new properties are added with their keys as Strings, and values must be:
-
A scalar of type Numeric (integer and floating-point numbers are both accepted), true, false, or nil;
-
A String or Symbol (and Symbols are converted to Strings before being used);
-
A Time;
-
A Hash, which will be recursively added using its key, plus an underscore, as the prefix (that is,
{ :foo => { :bar => :baz }}
will become{ 'foo_bar' => 'baz' }
); -
An object that responds to
#to_event_properties
, which must in turn return a Hash; #to_event_properties will be called, and it will then be treated exactly like a Hash, above.
prefix
and depth
are only used for internal recursive calls:
prefix
is a prefix that should be applied to all keys in the source
Hash before merging them into the target
Hash. (Nothing is added to this prefix first, so, if you want an underscore separating it from the key, include the underscore in the prefix
.)
depth
should be an integer, indicating how many layers of recursive calls we’ve invoked; this is simply to prevent infinite recursion – if this exceeds MAX_DEPTH
, above, then an exception will be raised.
480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 |
# File 'lib/meta_events/tracker.rb', line 480 def merge_properties(target, source, separator, prefix = nil, depth = 0) if depth > MAX_DEPTH raise "Nesting in EventTracker is too great; do you have a circular reference? " + "We reached depth: #{depth.inspect}; expanding: #{source.inspect} with prefix #{prefix.inspect} into #{target.inspect}" end unless source.kind_of?(Hash) raise ArgumentError, "You must supply a Hash for properties at #{prefix.inspect}; you supplied: #{source.inspect}" end source.each do |key, value| prefixed_key = "#{prefix}#{key}" if target.has_key?(prefixed_key) raise PropertyCollisionError, %{Because of hash delegation, multiple properties with the key #{prefixed_key.inspect} are present. This can happen, for example, if you do this: event!(:foo_bar => 'baz', :foo => { :bar => 'quux' }) ...since we will expand the second hash into a :foo_bar key, but there is already one present.} end net_value = normalize_scalar_property_value(value) if net_value == :invalid_property_value with_separator = "#{prefixed_key}#{separator}" if value.kind_of?(Hash) merge_properties(target, value, separator, with_separator, depth + 1) elsif value.respond_to?(:to_event_properties) merge_properties(target, value.to_event_properties, separator, with_separator, depth + 1) else raise ArgumentError, "Event property #{prefixed_key.inspect} is not a valid scalar, Hash, or object that " + "responds to #to_event_properties, but rather #{value.inspect} (#{value.class.name})." end else target[prefixed_key] = net_value end end end |
.normalize_scalar_property_value(value) ⇒ Object
Given a potential scalar value for a property, either returns the value that should actually be set in the resulting set of properties (for example, converting Symbols to Strings) or returns :invalid_property_value
if that isn’t a valid scalar value for a property.
525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 |
# File 'lib/meta_events/tracker.rb', line 525 def normalize_scalar_property_value(value) return "NaN" if value.kind_of?(Float) && value.nan? case value when true, false, nil then value when ActiveSupport::Duration then value.to_i when FLOAT_INFINITY then "+infinity" when -FLOAT_INFINITY then "-infinity" when Numeric then value when String then value.strip when Symbol then value.to_s.strip when Time then value.getutc.strftime("%Y-%m-%dT%H:%M:%S") when Array then out = value.map { |e| normalize_scalar_property_value(e) } out = :invalid_property_value if out.detect { |e| e == :invalid_property_value } out else :invalid_property_value end end |
Instance Method Details
#effective_properties(category_name, event_name, additional_properties = { }) ⇒ Object
Given a category, an event, and (optionally) additional properties, performs all of the expansion and validation of #event!, but does not actually fire the event – rather, returns a Hash containing:
- :distinct_id
-
The
distinct_id
that should be passed with the event; this can benil
if there is no distinct ID being passed. - :event_name
-
The fully-qualified event name, including
global_events_prefix
and version number. - :external_name
-
The event name for use in an events backend. By default this is
:event_name
but can be overridden. - :properties
-
The full set of properties, expanded (so values will only be scalars, never Hashes or objects), with String keys, exactly as they should be passed to an events system.
This method can be used for many things, but its primary purpose is to support front-end (Javascript-fired) events: you can have it compute exactly the set of properties that should be attached to such events, embed them into the page (using HTML data
attributes, JavaScript literals, or any other storage mechanism you want), and then have the front-end fire them. This allows consistency between front-end and back-end events, and is another big advantage of MetaEvents.
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 |
# File 'lib/meta_events/tracker.rb', line 410 def effective_properties(category_name, event_name, additional_properties = { }) event = version_object.fetch_event(category_name, event_name) explicit = { } self.class.merge_properties(explicit, additional_properties, property_separator) properties = @implicit_properties.merge(explicit) event.validate!(properties) # We need to do this instead of just using || so that you can override a present distinct_id with nil. net_distinct_id = if properties.has_key?('distinct_id') then properties.delete('distinct_id') else self.distinct_id end event_external_name = event.external_name || external_name.call(event) raise TypeError, "The external name of an event must be a String" unless event_external_name.kind_of?(String) { :distinct_id => net_distinct_id, :event_name => event.full_name, :external_name => event_external_name, :properties => properties } end |
#event!(category_name, event_name, additional_properties = { }) ⇒ Object
Fires an event. category_name
must be the name of a category in the MetaEvents DSL (within the version that this Tracker is using – which is 1 if you haven’t changed it); event_name
must be the name of an event. additional_properties
, if present, must be a Hash; the properties supplied will be combined with any implicit properties defined on this Tracker, and sent along with the event.
additional_properties
can use the sub-hash and object syntax discussed, above, under the introduction to this class.
385 386 387 388 389 390 391 392 |
# File 'lib/meta_events/tracker.rb', line 385 def event!(category_name, event_name, additional_properties = { }) event_data = effective_properties(category_name, event_name, additional_properties) event_data[:properties] = { 'time' => Time.now.to_i }.merge(event_data[:properties]) self.event_receivers.each do |receiver| receiver.track(event_data[:distinct_id], event_data[:external_name], event_data[:properties]) end end |