diff --git a/snowplow-tracker/LICENSE-2.0.txt b/snowplow-tracker/LICENSE-2.0.txt new file mode 100644 index 0000000000..7a4a3ea242 --- /dev/null +++ b/snowplow-tracker/LICENSE-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/snowplow-tracker/README.md b/snowplow-tracker/README.md new file mode 100644 index 0000000000..dac689f899 --- /dev/null +++ b/snowplow-tracker/README.md @@ -0,0 +1,74 @@ +# Ruby Analytics for Snowplow +[![Gem Version](https://badge.fury.io/rb/snowplow-tracker.svg)](http://badge.fury.io/rb/snowplow-tracker) +[![Build Status](https://travis-ci.org/snowplow/snowplow-ruby-tracker.png?branch=master)](https://travis-ci.org/snowplow/snowplow-ruby-tracker) +[![Code Climate](https://codeclimate.com/github/snowplow/snowplow-ruby-tracker.png)](https://codeclimate.com/github/snowplow/snowplow-ruby-tracker) +[![Coverage Status](https://coveralls.io/repos/snowplow/snowplow-ruby-tracker/badge.png)](https://coveralls.io/r/snowplow/snowplow-ruby-tracker) +[![License][license-image]][license] + +## Overview + +Add analytics to your Ruby and Rails apps and gems with the **[Snowplow] [snowplow]** event tracker for **[Ruby] [ruby]**. + +With this tracker you can collect event data from your **[Ruby] [ruby]** applications, **[Ruby on Rails] [rails]** web applications and **[Ruby gems] [rubygems]**. + +## Quickstart + +Assuming git, **[Vagrant] [vagrant-install]** and **[VirtualBox] [virtualbox-install]** installed: + +```bash + host$ git clone https://github.com/snowplow/snowplow-ruby-tracker.git + host$ cd snowplow-ruby-tracker + host$ vagrant up && vagrant ssh +guest$ cd /vagrant +guest$ gem install bundler +guest$ bundle install +guest$ rspec +``` + +## Publishing + +```bash + host$ vagrant push +``` + +## Find out more + +| Technical Docs | Setup Guide | Roadmap | Contributing | +|---------------------------------|---------------------------|-------------------------|-----------------------------------| +| ![i1] [techdocs-image] | ![i2] [setup-image] | ![i3] [roadmap-image] | ![i4] [contributing-image] | +| **[Technical Docs] [techdocs]** | **[Setup Guide] [setup]** | **[Roadmap] [roadmap]** | **[Contributing] [contributing]** | + +## Copyright and license + +The Snowplow Ruby Tracker is copyright 2013-2016 Snowplow Analytics Ltd. + +Licensed under the **[Apache License, Version 2.0] [license]** (the "License"); +you may not use this software except in compliance with the License. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +[license-image]: http://img.shields.io/badge/license-Apache--2-blue.svg?style=flat +[license]: http://www.apache.org/licenses/LICENSE-2.0 + +[ruby]: https://www.ruby-lang.org/en/ +[rails]: http://rubyonrails.org/ +[rubygems]: https://rubygems.org/ + +[snowplow]: http://snowplowanalytics.com + +[vagrant-install]: http://docs.vagrantup.com/v2/installation/index.html +[virtualbox-install]: https://www.virtualbox.org/wiki/Downloads + +[techdocs-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/techdocs.png +[setup-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/setup.png +[roadmap-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/roadmap.png +[contributing-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/contributing.png + +[techdocs]: https://github.com/snowplow/snowplow/wiki/Ruby-Tracker +[setup]: https://github.com/snowplow/snowplow/wiki/Ruby-Tracker-Setup +[roadmap]: https://github.com/snowplow/snowplow/wiki/Ruby-Tracker-Roadmap +[contributing]: https://github.com/snowplow/snowplow/wiki/Ruby-Tracker-Contributing diff --git a/snowplow-tracker/lib/snowplow-tracker.rb b/snowplow-tracker/lib/snowplow-tracker.rb new file mode 100644 index 0000000000..a08defef22 --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker.rb @@ -0,0 +1,24 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:snowplow-user@googlegroups.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +require 'snowplow-tracker/contracts.rb' +require 'snowplow-tracker/version.rb' +require 'snowplow-tracker/self_describing_json.rb' +require 'snowplow-tracker/payload.rb' +require 'snowplow-tracker/subject.rb' +require 'snowplow-tracker/emitters.rb' +require 'snowplow-tracker/timestamp.rb' +require 'snowplow-tracker/tracker.rb' + diff --git a/snowplow-tracker/lib/snowplow-tracker/contracts.rb b/snowplow-tracker/lib/snowplow-tracker/contracts.rb new file mode 100644 index 0000000000..0ce2907b24 --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/contracts.rb @@ -0,0 +1,29 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +require 'contracts' + +module SnowplowTracker + + ORIGINAL_FAILURE_CALLBACK = Contract.method(:failure_callback) + + def self.disable_contracts + Contract.define_singleton_method(:failure_callback) {|data| true} + end + + def self.enable_contracts + Contract.define_singleton_method(:failure_callback, ORIGINAL_FAILURE_CALLBACK) + end +end diff --git a/snowplow-tracker/lib/snowplow-tracker/emitters.rb b/snowplow-tracker/lib/snowplow-tracker/emitters.rb new file mode 100644 index 0000000000..09c75d199e --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/emitters.rb @@ -0,0 +1,280 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +require 'net/https' +require 'set' +require 'logger' +require 'contracts' + +module SnowplowTracker + + LOGGER = Logger.new(STDERR) + LOGGER.level = Logger::INFO + + class Emitter + + include Contracts + + @@ConfigHash = ({ + :protocol => Maybe[Or['http', 'https']], + :port => Maybe[Num], + :method => Maybe[Or['get', 'post']], + :buffer_size => Maybe[Num], + :on_success => Maybe[Func[Num => Any]], + :on_failure => Maybe[Func[Num, Hash => Any]], + :thread_count => Maybe[Num] + }) + + @@StrictConfigHash = And[@@ConfigHash, lambda { |x| + x.class == Hash and Set.new(x.keys).subset? Set.new(@@ConfigHash.keys) + }] + + @@DefaultConfig = { + :protocol => 'http', + :method => 'get' + } + + Contract String, @@StrictConfigHash => lambda { |x| x.is_a? Emitter } + def initialize(endpoint, config={}) + config = @@DefaultConfig.merge(config) + @lock = Monitor.new + @collector_uri = as_collector_uri(endpoint, config[:protocol], config[:port], config[:method]) + @buffer = [] + if not config[:buffer_size].nil? + @buffer_size = config[:buffer_size] + elsif config[:method] == 'get' + @buffer_size = 1 + else + @buffer_size = 10 + end + @method = config[:method] + @on_success = config[:on_success] + @on_failure = config[:on_failure] + LOGGER.info("#{self.class} initialized with endpoint #{@collector_uri}") + + self + end + + # Build the collector URI from the configuration hash + # + Contract String, String, Maybe[Num], String => String + def as_collector_uri(endpoint, protocol, port, method) + port_string = port == nil ? '' : ":#{port.to_s}" + path = method == 'get' ? '/i' : '/com.snowplowanalytics.snowplow/tp2' + + "#{protocol}://#{endpoint}#{port_string}#{path}" + end + + # Add an event to the buffer and flush it if maximum size has been reached + # + Contract Hash => nil + def input(payload) + payload.each { |k,v| payload[k] = v.to_s} + @lock.synchronize do + @buffer.push(payload) + if @buffer.size >= @buffer_size + flush + end + end + + nil + end + + # Flush the buffer + # + Contract Bool => nil + def flush(async=true) + @lock.synchronize do + send_requests(@buffer) + @buffer = [] + end + nil + end + + # Send all events in the buffer to the collector + # + Contract ArrayOf[Hash] => nil + def send_requests(evts) + if evts.size < 1 + LOGGER.info("Skipping sending events since buffer is empty") + return + end + LOGGER.info("Attempting to send #{evts.size} request#{evts.size == 1 ? '' : 's'}") + + evts.each do |event| + event['stm'] = (Time.now.to_f * 1000).to_i.to_s # add the sent timestamp, overwrite if already exists + end + + if @method == 'post' + post_succeeded = false + begin + request = http_post(SelfDescribingJson.new( + 'iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4', + evts + ).to_json) + post_succeeded = is_good_status_code(request.code) + rescue StandardError => se + LOGGER.warn(se) + end + if post_succeeded + unless @on_success.nil? + @on_success.call(evts.size) + end + else + unless @on_failure.nil? + @on_failure.call(0, evts) + end + end + + elsif @method == 'get' + success_count = 0 + unsent_requests = [] + evts.each do |evt| + get_succeeded = false + begin + request = http_get(evt) + get_succeeded = is_good_status_code(request.code) + rescue StandardError => se + LOGGER.warn(se) + end + if get_succeeded + success_count += 1 + else + unsent_requests << evt + end + end + if unsent_requests.size == 0 + unless @on_success.nil? + @on_success.call(success_count) + end + else + unless @on_failure.nil? + @on_failure.call(success_count, unsent_requests) + end + end + end + + nil + end + + # Send a GET request + # + Contract Hash => lambda { |x| x.is_a? Net::HTTPResponse } + def http_get(payload) + destination = URI(@collector_uri + '?' + URI.encode_www_form(payload)) + LOGGER.info("Sending GET request to #{@collector_uri}...") + LOGGER.debug("Payload: #{payload}") + http = Net::HTTP.new(destination.host, destination.port) + request = Net::HTTP::Get.new(destination.request_uri) + if destination.scheme == 'https' + http.use_ssl = true + end + response = http.request(request) + LOGGER.add(is_good_status_code(response.code) ? Logger::INFO : Logger::WARN) { + "GET request to #{@collector_uri} finished with status code #{response.code}" + } + + response + end + + # Send a POST request + # + Contract Hash => lambda { |x| x.is_a? Net::HTTPResponse } + def http_post(payload) + LOGGER.info("Sending POST request to #{@collector_uri}...") + LOGGER.debug("Payload: #{payload}") + destination = URI(@collector_uri) + http = Net::HTTP.new(destination.host, destination.port) + request = Net::HTTP::Post.new(destination.request_uri) + if destination.scheme == 'https' + http.use_ssl = true + end + request.body = payload.to_json + request.set_content_type('application/json; charset=utf-8') + response = http.request(request) + LOGGER.add(is_good_status_code(response.code) ? Logger::INFO : Logger::WARN) { + "POST request to #{@collector_uri} finished with status code #{response.code}" + } + + response + end + + # Only 2xx and 3xx status codes are considered successes + # + Contract String => Bool + def is_good_status_code(status_code) + status_code.to_i >= 200 && status_code.to_i < 400 + end + + private :as_collector_uri, + :http_get, + :http_post + + end + + + class AsyncEmitter < Emitter + + Contract String, @@StrictConfigHash => lambda { |x| x.is_a? Emitter } + def initialize(endpoint, config={}) + @queue = Queue.new() + # @all_processed_condition and @results_unprocessed are used to emulate Python's Queue.task_done() + @queue.extend(MonitorMixin) + @all_processed_condition = @queue.new_cond + @results_unprocessed = 0 + (config[:thread_count] || 1).times do + t = Thread.new do + consume + end + end + super(endpoint, config) + end + + def consume + loop do + work_unit = @queue.pop + send_requests(work_unit) + @queue.synchronize do + @results_unprocessed -= 1 + @all_processed_condition.broadcast + end + end + end + + # Flush the buffer + # If async is false, block until the queue is empty + # + def flush(async=true) + loop do + @lock.synchronize do + @queue.synchronize do + @results_unprocessed += 1 + end + @queue << @buffer + @buffer = [] + end + if not async + LOGGER.info('Starting synchronous flush') + @queue.synchronize do + @all_processed_condition.wait_while { @results_unprocessed > 0 } + LOGGER.info('Finished synchronous flush') + end + end + break if @buffer.size < 1 + end + end + end + +end diff --git a/snowplow-tracker/lib/snowplow-tracker/payload.rb b/snowplow-tracker/lib/snowplow-tracker/payload.rb new file mode 100644 index 0000000000..383f525269 --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/payload.rb @@ -0,0 +1,73 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +require 'base64' +require 'json' +require 'net/http' +require 'contracts' + +module SnowplowTracker + + class Payload + + include Contracts + + attr_reader :context + + Contract nil => Payload + def initialize + @context = {} + self + end + + # Add a single name-value pair to @context + # + Contract String, Or[String, Bool, Num, nil] => Or[String, Bool, Num, nil] + def add(name, value) + if value != "" and not value.nil? + @context[name] = value + end + end + + # Add each name-value pair in dict to @context + # + Contract Hash => Hash + def add_dict(dict) + for f in dict + self.add(f[0], f[1]) + end + end + + # Stringify a JSON and add it to @context + # + Contract Maybe[Hash], Bool, String, String => Maybe[String] + def add_json(dict, encode_base64, type_when_encoded, type_when_not_encoded) + + if dict.nil? + return + end + + dict_string = JSON.generate(dict) + + if encode_base64 + self.add(type_when_encoded, Base64.strict_encode64(dict_string)) + else + self.add(type_when_not_encoded, dict_string) + end + + end + + end +end diff --git a/snowplow-tracker/lib/snowplow-tracker/self_describing_json.rb b/snowplow-tracker/lib/snowplow-tracker/self_describing_json.rb new file mode 100644 index 0000000000..7b917c1b00 --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/self_describing_json.rb @@ -0,0 +1,34 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +module SnowplowTracker + + class SelfDescribingJson + + def initialize(schema, data) + @schema = schema + @data = data + end + + def to_json + { + :schema => @schema, + :data => @data + } + end + + end + +end diff --git a/snowplow-tracker/lib/snowplow-tracker/subject.rb b/snowplow-tracker/lib/snowplow-tracker/subject.rb new file mode 100644 index 0000000000..09d2bdfb60 --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/subject.rb @@ -0,0 +1,139 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +require 'contracts' + +module SnowplowTracker + + class Subject + + include Contracts + + @@default_platform = 'srv' + @@supported_platforms = ['pc', 'tv', 'mob', 'cnsl', 'iot'] + + attr_reader :standard_nv_pairs + + Contract None => Subject + def initialize + @standard_nv_pairs = {"p" => @@default_platform} + self + end + + # Specify the platform + # + Contract String => Subject + def set_platform(value) + if @@supported_platforms.include?(value) + @standard_nv_pairs['p'] = value + else + raise "#{value} is not a supported platform" + end + + self + end + + # Set the business-defined user ID for a user + # + Contract String => Subject + def set_user_id(user_id) + @standard_nv_pairs['uid'] = user_id + self + end + + # Set fingerprint for the user + # + Contract Num => Subject + def set_fingerprint(fingerprint) + @standard_nv_pairs['fp'] = fingerprint + self + end + + # Set the screen resolution for a device + # + Contract Num, Num => Subject + def set_screen_resolution(width, height) + @standard_nv_pairs['res'] = "#{width}x#{height}" + self + end + + # Set the dimensions of the current viewport + # + Contract Num, Num => Subject + def set_viewport(width, height) + @standard_nv_pairs['vp'] = "#{width}x#{height}" + self + end + + # Set the color depth of the device in bits per pixel + # + Contract Num => Subject + def set_color_depth(depth) + @standard_nv_pairs['cd'] = depth + self + end + + # Set the timezone field + # + Contract String => Subject + def set_timezone(timezone) + @standard_nv_pairs['tz'] = timezone + self + end + + # Set the language field + # + Contract String => Subject + def set_lang(lang) + @standard_nv_pairs['lang'] = lang + self + end + + # Set the domain user ID + # + Contract String => Subject + def set_domain_user_id(duid) + @standard_nv_pairs['duid'] = duid + self + end + + # Set the IP address field + # + Contract String => Subject + def set_ip_address(ip) + @standard_nv_pairs['ip'] = ip + self + end + + # Set the user agent + # + Contract String => Subject + def set_useragent(ua) + @standard_nv_pairs['ua'] = ua + self + end + + # Set the network user ID field + # This overwrites the nuid field set by the collector + # + Contract String => Subject + def set_network_user_id(nuid) + @standard_nv_pairs['tnuid'] = nuid + self + end + + end + +end diff --git a/snowplow-tracker/lib/snowplow-tracker/timestamp.rb b/snowplow-tracker/lib/snowplow-tracker/timestamp.rb new file mode 100644 index 0000000000..d81a12850c --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/timestamp.rb @@ -0,0 +1,46 @@ +# Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun, Ed Lewis (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2016 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +module SnowplowTracker + + class Timestamp + + attr_reader :type + attr_reader :value + + def initialize(type, value) + @type = type + @value = value + end + + end + + class TrueTimestamp < Timestamp + + def initialize(value) + super 'ttm', value + end + + end + + class DeviceTimestamp < Timestamp + + def initialize(value) + super 'dtm', value + end + + end + +end \ No newline at end of file diff --git a/snowplow-tracker/lib/snowplow-tracker/tracker.rb b/snowplow-tracker/lib/snowplow-tracker/tracker.rb new file mode 100644 index 0000000000..f73dcef505 --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/tracker.rb @@ -0,0 +1,371 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +require 'contracts' +require 'securerandom' +require 'set' + +module SnowplowTracker + + class Tracker + + include Contracts + + @@EmitterInput = Or[lambda {|x| x.is_a? Emitter}, ArrayOf[lambda {|x| x.is_a? Emitter}]] + + @@required_transaction_keys = Set.new(%w(order_id total_value)) + @@recognised_transaction_keys = Set.new(%w(order_id total_value affiliation tax_value shipping city state country currency)) + + @@Transaction = lambda { |x| + return false unless x.class == Hash + transaction_keys = Set.new(x.keys) + @@required_transaction_keys.subset? transaction_keys and + transaction_keys.subset? @@recognised_transaction_keys + } + + @@required_item_keys = Set.new(%w(sku price quantity)) + @@recognised_item_keys = Set.new(%w(sku price quantity name category context)) + + @@Item = lambda { |x| + return false unless x.class == Hash + item_keys = Set.new(x.keys) + @@required_item_keys.subset? item_keys and + item_keys.subset? @@recognised_item_keys + } + + @@required_augmented_item_keys = Set.new(%w(sku price quantity tstamp order_id)) + @@recognised_augmented_item_keys = Set.new(%w(sku price quantity name category context tstamp order_id currency)) + + @@AugmentedItem = lambda { |x| + return false unless x.class == Hash + augmented_item_keys = Set.new(x.keys) + @@required_augmented_item_keys.subset? augmented_item_keys and + augmented_item_keys.subset? @@recognised_augmented_item_keys + } + + @@ContextsInput = ArrayOf[SelfDescribingJson] + + @@version = TRACKER_VERSION + @@default_encode_base64 = true + + @@base_schema_path = "iglu:com.snowplowanalytics.snowplow" + @@schema_tag = "jsonschema" + @@context_schema = "#{@@base_schema_path}/contexts/#{@@schema_tag}/1-0-1" + @@unstruct_event_schema = "#{@@base_schema_path}/unstruct_event/#{@@schema_tag}/1-0-0" + + Contract @@EmitterInput, Maybe[Subject], Maybe[String], Maybe[String], Bool => Tracker + def initialize(emitters, subject=nil, namespace=nil, app_id=nil, encode_base64=@@default_encode_base64) + @emitters = Array(emitters) + if subject.nil? + @subject = Subject.new + else + @subject = subject + end + @standard_nv_pairs = { + 'tna' => namespace, + 'tv' => @@version, + 'aid' => app_id + } + @config = { + 'encode_base64' => encode_base64 + } + + self + end + + # Call subject methods from tracker instance + # + Subject.instance_methods(false).each do |name| + define_method name, ->(*splat) do + @subject.method(name.to_sym).call(*splat) + + self + end + end + + # Generates a type-4 UUID to identify this event + Contract nil => String + def get_event_id() + SecureRandom.uuid + end + + # Generates the timestamp (in milliseconds) to be attached to each event + # + Contract nil => Num + def get_timestamp + (Time.now.to_f * 1000).to_i + end + + # Builds a self-describing JSON from an array of custom contexts + # + Contract @@ContextsInput => Hash + def build_context(context) + SelfDescribingJson.new( + @@context_schema, + context.map {|c| c.to_json} + ).to_json + end + + # Tracking methods + + # Attaches all the fields in @standard_nv_pairs to the request + # Only attaches the context vendor if the event has a custom context + # + Contract Payload => nil + def track(pb) + pb.add_dict(@subject.standard_nv_pairs) + pb.add_dict(@standard_nv_pairs) + pb.add('eid', get_event_id()) + @emitters.each{ |emitter| emitter.input(pb.context)} + + nil + end + + # Log a visit to this page with an inserted device timestamp + # + Contract String, Maybe[String], Maybe[String], Maybe[@@ContextsInput], Maybe[Num] => Tracker + def track_page_view(page_url, page_title=nil, referrer=nil, context=nil, tstamp=nil) + if tstamp.nil? + tstamp = get_timestamp + end + + track_page_view(page_url, page_title, referrer, context, DeviceTimestamp.new(tstamp)) + end + + # Log a visit to this page + # + Contract String, Maybe[String], Maybe[String], Maybe[@@ContextsInput], SnowplowTracker::Timestamp => Tracker + def track_page_view(page_url, page_title=nil, referrer=nil, context=nil, tstamp=nil) + pb = Payload.new + pb.add('e', 'pv') + pb.add('url', page_url) + pb.add('page', page_title) + pb.add('refr', referrer) + + unless context.nil? + pb.add_json(build_context(context), @config['encode_base64'], 'cx', 'co') + end + + pb.add(tstamp.type, tstamp.value) + + track(pb) + + self + end + + # Track a single item within an ecommerce transaction + # Not part of the public API + # + Contract @@AugmentedItem => self + def track_ecommerce_transaction_item(argmap) + pb = Payload.new + pb.add('e', 'ti') + pb.add('ti_id', argmap['order_id']) + pb.add('ti_sk', argmap['sku']) + pb.add('ti_pr', argmap['price']) + pb.add('ti_qu', argmap['quantity']) + pb.add('ti_nm', argmap['name']) + pb.add('ti_ca', argmap['category']) + pb.add('ti_cu', argmap['currency']) + unless argmap['context'].nil? + pb.add_json(build_context(argmap['context']), @config['encode_base64'], 'cx', 'co') + end + pb.add(argmap['tstamp'].type, argmap['tstamp'].value) + track(pb) + + self + end + + # Track an ecommerce transaction and all the items in it + # Set the timestamp as the device timestamp + Contract @@Transaction, ArrayOf[@@Item], Maybe[@@ContextsInput], Maybe[Num] => Tracker + def track_ecommerce_transaction(transaction, + items, + context=nil, + tstamp=nil) + if tstamp.nil? + tstamp = get_timestamp + end + + track_ecommerce_transaction(transaction, items, context, DeviceTimestamp.new(tstamp)) + end + + # Track an ecommerce transaction and all the items in it + # + Contract @@Transaction, ArrayOf[@@Item], Maybe[@@ContextsInput], Timestamp => Tracker + def track_ecommerce_transaction(transaction, items, + context=nil, tstamp=nil) + pb = Payload.new + pb.add('e', 'tr') + pb.add('tr_id', transaction['order_id']) + pb.add('tr_tt', transaction['total_value']) + pb.add('tr_af', transaction['affiliation']) + pb.add('tr_tx', transaction['tax_value']) + pb.add('tr_sh', transaction['shipping']) + pb.add('tr_ci', transaction['city']) + pb.add('tr_st', transaction['state']) + pb.add('tr_co', transaction['country']) + pb.add('tr_cu', transaction['currency']) + unless context.nil? + pb.add_json(build_context(context), @config['encode_base64'], 'cx', 'co') + end + + pb.add(tstamp.type, tstamp.value) + + track(pb) + + for item in items + item['tstamp'] = tstamp + item['order_id'] = transaction['order_id'] + item['currency'] = transaction['currency'] + track_ecommerce_transaction_item(item) + end + + self + end + + # Track a structured event + # set the timestamp to the device timestamp + Contract String, String, Maybe[String], Maybe[String], Maybe[Num], Maybe[@@ContextsInput], Maybe[Num] => Tracker + def track_struct_event(category, action, label=nil, property=nil, value=nil, context=nil, tstamp=nil) + if tstamp.nil? + tstamp = get_timestamp + end + + track_struct_event(category, action, label, property, value, context, DeviceTimestamp.new(tstamp)) + end + # Track a structured event + # + Contract String, String, Maybe[String], Maybe[String], Maybe[Num], Maybe[@@ContextsInput], Timestamp => Tracker + def track_struct_event(category, action, label=nil, property=nil, value=nil, context=nil, tstamp=nil) + pb = Payload.new + pb.add('e', 'se') + pb.add('se_ca', category) + pb.add('se_ac', action) + pb.add('se_la', label) + pb.add('se_pr', property) + pb.add('se_va', value) + unless context.nil? + pb.add_json(build_context(context), @config['encode_base64'], 'cx', 'co') + end + + pb.add(tstamp.type, tstamp.value) + track(pb) + + self + end + + # Track a screen view event + # + Contract Maybe[String], Maybe[String], Maybe[@@ContextsInput], Or[Timestamp, Num, nil] => Tracker + def track_screen_view(name=nil, id=nil, context=nil, tstamp=nil) + screen_view_properties = {} + unless name.nil? + screen_view_properties['name'] = name + end + unless id.nil? + screen_view_properties['id'] = id + end + screen_view_schema = "#{@@base_schema_path}/screen_view/#{@@schema_tag}/1-0-0" + + event_json = SelfDescribingJson.new(screen_view_schema, screen_view_properties) + + self.track_unstruct_event(event_json, context, tstamp) + + self + end + + # Better name for track unstruct event + # + Contract SelfDescribingJson, Maybe[@@ContextsInput], Timestamp => Tracker + def track_self_describing_event(event_json, context=nil, tstamp=nil) + track_unstruct_event(event_json, context, tstamp) + end + + # Better name for track unstruct event + # set the timestamp to the device timestamp + Contract SelfDescribingJson, Maybe[@@ContextsInput], Maybe[Num] => Tracker + def track_self_describing_event(event_json, context=nil, tstamp=nil) + track_unstruct_event(event_json, context, tstamp) + end + + # Track an unstructured event + # set the timestamp to the device timstamp + Contract SelfDescribingJson, Maybe[@@ContextsInput], Maybe[Num] => Tracker + def track_unstruct_event(event_json, context=nil, tstamp=nil) + if tstamp.nil? + tstamp = get_timestamp + end + + track_unstruct_event(event_json, context, DeviceTimestamp.new(tstamp)) + end + + # Track an unstructured event + # + Contract SelfDescribingJson, Maybe[@@ContextsInput], Timestamp => Tracker + def track_unstruct_event(event_json, context=nil, tstamp=nil) + pb = Payload.new + pb.add('e', 'ue') + + envelope = SelfDescribingJson.new(@@unstruct_event_schema, event_json.to_json) + + pb.add_json(envelope.to_json, @config['encode_base64'], 'ue_px', 'ue_pr') + + unless context.nil? + pb.add_json(build_context(context), @config['encode_base64'], 'cx', 'co') + end + + pb.add(tstamp.type, tstamp.value) + + track(pb) + + self + end + + # Flush all events stored in all emitters + # + Contract Bool => Tracker + def flush(async=false) + @emitters.each do |emitter| + emitter.flush(async) + end + + self + end + + # Set the subject of the events fired by the tracker + # + Contract Subject => Tracker + def set_subject(subject) + @subject = subject + self + end + + # Add a new emitter + # + Contract Emitter => Tracker + def add_emitter(emitter) + @emitters.push(emitter) + self + end + + private :get_timestamp, + :build_context, + :track, + :track_ecommerce_transaction_item + + end + +end diff --git a/snowplow-tracker/lib/snowplow-tracker/version.rb b/snowplow-tracker/lib/snowplow-tracker/version.rb new file mode 100644 index 0000000000..18bde7bf60 --- /dev/null +++ b/snowplow-tracker/lib/snowplow-tracker/version.rb @@ -0,0 +1,19 @@ +# Copyright (c) 2013-2014 Snowplow Analytics Ltd. All rights reserved. +# +# This program is licensed to you under the Apache License Version 2.0, +# and you may not use this file except in compliance with the Apache License Version 2.0. +# You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the Apache License Version 2.0 is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +# Author:: Alex Dean, Fred Blundun (mailto:support@snowplowanalytics.com) +# Copyright:: Copyright (c) 2013-2014 Snowplow Analytics Ltd +# License:: Apache License Version 2.0 + +module SnowplowTracker + VERSION = '0.6.1' + TRACKER_VERSION = "rb-#{VERSION}" +end diff --git a/snowplow-tracker/snowplow-tracker.gemspec b/snowplow-tracker/snowplow-tracker.gemspec new file mode 100644 index 0000000000..c30cb26829 --- /dev/null +++ b/snowplow-tracker/snowplow-tracker.gemspec @@ -0,0 +1,41 @@ +######################################################### +# This file has been automatically generated by gem2tgz # +######################################################### +# -*- encoding: utf-8 -*- +# stub: snowplow-tracker 0.6.1 ruby lib + +Gem::Specification.new do |s| + s.name = "snowplow-tracker".freeze + s.version = "0.6.1" + + s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= + s.require_paths = ["lib".freeze] + s.authors = ["Alexander Dean".freeze, "Fred Blundun".freeze] + s.date = "2016-12-26" + s.description = "With this tracker you can collect event data from your Ruby applications, Ruby on Rails web applications and Ruby gems.".freeze + s.email = "support@snowplowanalytics.com".freeze + s.files = ["LICENSE-2.0.txt".freeze, "README.md".freeze, "lib/snowplow-tracker.rb".freeze, "lib/snowplow-tracker/contracts.rb".freeze, "lib/snowplow-tracker/emitters.rb".freeze, "lib/snowplow-tracker/payload.rb".freeze, "lib/snowplow-tracker/self_describing_json.rb".freeze, "lib/snowplow-tracker/subject.rb".freeze, "lib/snowplow-tracker/timestamp.rb".freeze, "lib/snowplow-tracker/tracker.rb".freeze, "lib/snowplow-tracker/version.rb".freeze] + s.homepage = "http://github.com/snowplow/snowplow-ruby-tracker".freeze + s.licenses = ["Apache License 2.0".freeze] + s.required_ruby_version = Gem::Requirement.new(">= 2.0.0".freeze) + s.rubygems_version = "2.5.2.1".freeze + s.summary = "Ruby Analytics for Snowplow".freeze + + if s.respond_to? :specification_version then + s.specification_version = 4 + + if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then + s.add_runtime_dependency(%q.freeze, ["<= 0.11", "~> 0.7"]) + s.add_development_dependency(%q.freeze, ["~> 2.14.1"]) + s.add_development_dependency(%q.freeze, ["~> 1.17.4"]) + else + s.add_dependency(%q.freeze, ["<= 0.11", "~> 0.7"]) + s.add_dependency(%q.freeze, ["~> 2.14.1"]) + s.add_dependency(%q.freeze, ["~> 1.17.4"]) + end + else + s.add_dependency(%q.freeze, ["<= 0.11", "~> 0.7"]) + s.add_dependency(%q.freeze, ["~> 2.14.1"]) + s.add_dependency(%q.freeze, ["~> 1.17.4"]) + end +end