path-reporting 0.1.1 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d921c4f5d8a011b077006a44cb6800d3f9d63329b46d750626aa9ff9fd17859
4
- data.tar.gz: 1828887be14dbb2b8cbbe5a382e36e14800fce6d5b306764234e0f1f974729ac
3
+ metadata.gz: 134152ef7ea6fed2b3b09883f364a31ebb1e21ef6b5f6cd3db8108ddc750de8a
4
+ data.tar.gz: f059d45004af1ba04ae2b8af2838de5a7fbbaa4575f53873123204546f1190fb
5
5
  SHA512:
6
- metadata.gz: 213c4dffd2afaaf7589ce520aa6cbe78d9ecf998ef0528fe9f35e1539d0b547a292a5ec0c42cd9f6031b25ab31628baca91356abbcb0eda2ebbc6a4a7e702bd5
7
- data.tar.gz: 624e433365f0b3099c3c2d5e3eed73ca6aa2ba7648fd4df7590ebd578dc0752e1cea64a1b30073fe34b3d1386ca7dcde1683237db61e90d65981e3290c0504a0
6
+ metadata.gz: d9634c2cf01eeaa497d1b3df429568f40bb6d714235aa8f0922b9196376301679c72d5d58f48e8f942f852553712f72a6577edb96e4dfd8e3ccfd5473e5acb36
7
+ data.tar.gz: aeb9e1f444090af5ed104917b222327d3fe8dd95147964b7f8297065252b8d10feb19445cf5df4eb74724aa7807824e67a6939ca82c9aa23793d124e18e886f0
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.0.3
data/.yardopts CHANGED
@@ -1 +1 @@
1
- --no-private --plugin rspec
1
+ --no-private --plugin rspec -m markdown --title="Path Reporting"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- path-reporting (0.1.1)
4
+ path-reporting (0.1.4)
5
5
  amplitude-api
6
6
 
7
7
  GEM
@@ -61,6 +61,7 @@ GEM
61
61
 
62
62
  PLATFORMS
63
63
  arm64-darwin-21
64
+ x86_64-linux
64
65
 
65
66
  DEPENDENCIES
66
67
  amplitude-api (~> 0.4.1)
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Path CCM
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,8 +1,12 @@
1
- # Reporting
1
+ # Path Reporting
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/reporting`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/pathccm/reporting/Ruby?style=flat-square) ![Gem](https://img.shields.io/gem/v/path-reporting?style=flat-square) ![Libraries.io dependency status for latest release](https://img.shields.io/librariesio/release/rubygems/path-reporting?style=flat-square) ![GitHub](https://img.shields.io/github/license/pathccm/reporting?style=flat-square)
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ The one stop shop for reporting at [Path](https://pathmentalhealth.com)
6
+
7
+ This gem contains (or will contain) all the various types of reporting we need
8
+ to do at Path. From metrics to analytics to performance and beyond, this gem
9
+ is meant to enable us to report anything we need as simply as possible.
6
10
 
7
11
  ## Installation
8
12
 
@@ -16,7 +20,35 @@ If bundler is not being used to manage dependencies, install the gem by executin
16
20
 
17
21
  ## Usage
18
22
 
19
- TODO: Write usage instructions here
23
+ First you will need to initialize and configure the module
24
+
25
+ ```ruby
26
+ Path::Reporting.init do |config|
27
+ config.analytics.logger = Rails.logger
28
+ end
29
+ ```
30
+
31
+ See [our Configuration docs](https://www.rubydoc.info/gems/path-reporting/Path/Reporting/Configuration)
32
+ for more information on how to configure this module
33
+
34
+ ### Analytics
35
+
36
+ After initialing the module, record an analytics event with the following code.
37
+
38
+ ```ruby
39
+
40
+ Path::Reporting.analytics.record(
41
+ product_code: Constants::ANALYTICS_PRODUCT_CODE,
42
+ product_area: Constants::ANALYTICS_PRODUCT_AREA_MATCHING,
43
+ name: 'Preferred provider multiple valid matches',
44
+ user: @contact.analytics_friendly_hash,
45
+ user_type: Path::Reporting::UserType::PATIENT,
46
+ trigger: Path::Reporting::Trigger::PAGE_VIEW,
47
+ metadata: analytics_metadata,
48
+ )
49
+ ```
50
+
51
+ Be sure to read our [Analytics Guide (Internal Doc)](https://docs.google.com/document/d/1axnk1EkKCb__sxtvMomrPNup3wsviDOAefQWwXU3Z3U/edit#)
20
52
 
21
53
  ## Development
22
54
 
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "amplitude-api"
4
+
5
+ # See https://developers.amplitude.com/docs/http-api-v2#schemauploadrequestbody
6
+ API_METADATA_TO_ELEVATE = [
7
+ "device_id",
8
+ "app_version",
9
+ "platform",
10
+ "os_name",
11
+ "os_version",
12
+ "device_brand",
13
+ "device_manufacturer",
14
+ "device_model",
15
+ "carrier",
16
+ "country",
17
+ "region",
18
+ # We do not elevate city because at that level it is possibly PII
19
+ "dma",
20
+ "language",
21
+ "price",
22
+ "quantity",
23
+ "revenue",
24
+ "productId",
25
+ "revenueType",
26
+ # We do not elevate lat/long/IP because it is PII (IP at least for analytics)
27
+ "event_id",
28
+ "session_id",
29
+ "insert_id",
30
+ "plan"
31
+ ].freeze
32
+
33
+ # Amplitue is not HIPAA compliant, so there are a number of PII things we want
34
+ # to make sure to filter out. These are keys (all lowercase) that are things
35
+ # we want to filter. Lowercased matching keys in data are obfuscated.
36
+ DISALLOWED_METADATA_PII_KEYS = %w[
37
+ email
38
+ name
39
+ first_name
40
+ firstname
41
+ last_name
42
+ lastname
43
+ zip
44
+ ssn
45
+ dob
46
+ address
47
+ phone
48
+ contactinfo
49
+ patient_chart_id
50
+ ].freeze
51
+
52
+ # Don't clean to infinity (and beyond)
53
+ MAX_METADATA_DEPTH = 4
54
+
55
+ module Path
56
+ module Reporting
57
+ class Analytics
58
+ # Amplitude analytics is our primary analytics channel for production
59
+ class Amplitude
60
+ # Setup and configure AmplitudeAPI with the given configuration
61
+ # @param config [AmplitudeAPI::Config] the configuration for AmplitudeAPI
62
+ # @see https://www.rubydoc.info/gems/amplitude-api/AmplitudeAPI/Config AmplitudeAPI::Config Documentation
63
+ # @see https://github.com/toothrot/amplitude-api AmplitudeAPI repository
64
+ def initialize(config)
65
+ @config = config
66
+
67
+ config.amplitude_config.each do |key, value|
68
+ AmplitudeAPI.config.instance_variable_set("@#{key}", value)
69
+ end
70
+ end
71
+
72
+ # Record the metadata to Amplitude
73
+ # @param name [String] Formatted name to send to Amplitude
74
+ # @param user [Hash] User object. Must contain `:id` and no PII
75
+ # @param user_type [UserType] Type of `user`
76
+ # @param trigger [Trigger] Trigger for this event
77
+ # @param metadata [Hash] Metadata to send with the event
78
+ # @see Analytics
79
+ def record(name:, user:, user_type:, trigger:, metadata: {})
80
+ user = user.dup
81
+ metadata = metadata.dup
82
+ user[:user_type] = user_type
83
+ metadata[:trigger] = trigger
84
+
85
+ event_props = {
86
+ user_id: user[:id].to_s,
87
+ user_properties: scrub_pii(user),
88
+ event_type: name,
89
+ event_properties: scrub_pii(metadata)
90
+ }
91
+ API_METADATA_TO_ELEVATE.each do |key|
92
+ event_props[key] = metadata[key] if metadata.key? key
93
+ end
94
+
95
+ AmplitudeAPI.track AmplitudeAPI::Event.new event_props
96
+ end
97
+
98
+ # Scrub known PII keys from a hash
99
+ # @private
100
+ def scrub_pii(data, depth: 0)
101
+ return "[DATA]" if depth > MAX_METADATA_DEPTH
102
+
103
+ case data
104
+ when Hash
105
+ data = data.dup
106
+ # Replace any disallowed keys, and recurse for allowed values
107
+ data.each do |key, val|
108
+ data[key] = if DISALLOWED_METADATA_PII_KEYS.include? key.to_s
109
+ "XXXXXXXX"
110
+ else
111
+ scrub_pii(val, depth: depth + 1)
112
+ end
113
+ end
114
+ when Array
115
+ return data.map { |item| scrub_pii(item, depth: depth + 1) }
116
+ end
117
+
118
+ data
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Path
4
+ module Reporting
5
+ class Analytics
6
+ # Configuration for analytics reporting. Generally this is for
7
+ # configuring amplitude and/or a logger that analytics should
8
+ # be reported to
9
+ # @!attribute amplitude_config
10
+ # Set the configuration for the AmplitudeAPI gem
11
+ # @return [Hash] amplitude configuration options as passed along to the amplitude-api gem
12
+ # @see https://www.rubydoc.info/gems/amplitude-api/AmplitudeAPI/Config AmplitudeAPI::Config
13
+ # @!attribute logger
14
+ # The logger for the console logging analytics reporter
15
+ # @return [#info] a logging interface with a .info method we can log analytics to
16
+ # @example
17
+ # conf.logger = Rails.logger
18
+ class Configuration
19
+ attr_reader :amplitude_config, :logger
20
+
21
+ # New Configuration with all reporting off by default
22
+ def initialize
23
+ @console_enabled = false
24
+ @logger = nil
25
+ @amplitude_enabled = false
26
+ @amplitude_config = nil
27
+ end
28
+
29
+ def logger=(logger)
30
+ @console_enabled = !logger.nil?
31
+ @logger = logger
32
+ end
33
+
34
+ alias console= logger=
35
+
36
+ # Check if the logger has been configured and is available for use
37
+ # @return [Boolean] if logger is available for use
38
+ def console_enabled?
39
+ @console_enabled && @logger
40
+ end
41
+
42
+ # Set the configuration for the AmplitudeAPI gem
43
+ # @param conf [Hash] configuration options for amplitude
44
+ # @return [Hash] amplitude configuration options as passed along to the amplitude-api gem
45
+ # @see https://www.rubydoc.info/gems/amplitude-api/AmplitudeAPI/Config AmplitudeAPI::Config
46
+ def amplitude_config=(conf)
47
+ @amplitude_enabled = !conf.nil?
48
+ @amplitude_config = conf
49
+ end
50
+
51
+ alias amplitude= amplitude_config=
52
+
53
+ # Check if Amplitude has been configured and is available for use
54
+ # @return [Boolean] if Amplitude is available for use
55
+ def amplitude_enabled?
56
+ @amplitude_enabled && @amplitude_config
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Path
4
+ module Reporting
5
+ class Analytics
6
+ # Simple non-structure console logging for analytics data. Most helpful
7
+ # for development or backup rather than analysis.
8
+ class Console
9
+ # Create new console logging analytics reporter
10
+ # @param config [Analytics::Configuration] the configuration for the reporter
11
+ # @see Analytics::Configuration
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # Log the analytics event to the configured logger
17
+ # @param name [String] Formatted name to send to Amplitude
18
+ # @param user [Hash] User object. Must contain `:id` and no PII
19
+ # @param user_type [UserType] Type of `user`
20
+ # @param trigger [Trigger] Trigger for this event
21
+ # @param metadata [Hash] Metadata to send with the event
22
+ # @see Analytics
23
+ def record(name:, user:, user_type:, trigger:, metadata: {})
24
+ @config.logger.info("[#{trigger}]:#{name} - #{user.inspect} (#{user_type}) #{metadata.nil? ? "" : metadata.inspect}")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "analytics/amplitude"
4
+ require_relative "analytics/console"
5
+ require_relative "types/trigger"
6
+ require_relative "types/user_type"
7
+
8
+ module Path
9
+ module Reporting
10
+ # Our primary class for reporting analytics data. Once configured, this
11
+ # class can report analytics to any and all enabled and configured
12
+ # reporters.
13
+ #
14
+ # This class is not a singleton, but is exposed via the {Reporting.analytics}
15
+ # property once the Reporting module is initialized
16
+ class Analytics
17
+ # Create a new analytics reporter with the given configuration
18
+ # @param config [Analytics::Configuration] configuration to use
19
+ def initialize(config)
20
+ @config = config
21
+ end
22
+
23
+ # Get clients for reporting events based on environment
24
+ # @private
25
+ def clients
26
+ if @clients.nil?
27
+ @clients = {
28
+ amplitude: setup_amplitude,
29
+ console: setup_console
30
+ }
31
+ end
32
+
33
+ @clients
34
+ end
35
+
36
+ # @private
37
+ def setup_amplitude
38
+ # Amplitude reporting for metrics
39
+ return Path::Reporting::Analytics::Amplitude.new @config if @config.amplitude_enabled?
40
+
41
+ nil
42
+ end
43
+
44
+ # @private
45
+ def setup_console
46
+ return Path::Reporting::Analytics::Console.new @config if @config.console_enabled?
47
+
48
+ nil
49
+ end
50
+
51
+ # Normalize the event name for easier searching in our analytics tools
52
+ #
53
+ # Generally the format is:
54
+ # - Product code: All uppercase, _ separator between words
55
+ # - Product area: Upper camel case, no spaces or separators
56
+ # - (Event) Name: Sentence case, _ separator between words
57
+ # @private
58
+ def format_event_name(product_code:, product_area:, name:)
59
+ formatted_code = product_code.gsub(" ", "_").upcase
60
+ formatted_area = product_area.split(" ").each(&:capitalize!).join("")
61
+ formatted_desc = name.capitalize.gsub(" ", "_")
62
+ "#{formatted_code}_#{formatted_area}_#{formatted_desc}"
63
+ end
64
+
65
+ # Record analytics data to our enabled analytics channel
66
+ #
67
+ # This is the primary (and at the moment only) way to report analytics
68
+ # data in our systems. Every configured analytics reporter will record
69
+ # the event with the data given here.
70
+ #
71
+ # @note ***No patient PII or PHI is allowed in our analytics data.***
72
+ # By default we will attempt to strip this out of user data as well
73
+ # as metadata, but this is imperfect and should not be relied on.
74
+ # Instead, proactively exclude that data before it gets here.
75
+ # @note The `product_code`, `product_area`, and `name` parameters will
76
+ # be formatted for easier searching automatically. Feel free to use
77
+ # regular text formatting here. E.g. "Self Scheduling" or "Hold booked"
78
+ #
79
+ # @param product_code [String] Code denoting which product this event
80
+ # occurred within. For example, "Self Scheduling". Can be used to view
81
+ # all the events that happen in a specific product
82
+ #
83
+ # Bias names toward whatever the major user-facing component is. For
84
+ # example, Therapy or Operations.
85
+ # @param product_area [String] Area of the product that event relates to.
86
+ # For example, “Appointment Booking” or “User Settings”. Can be used to
87
+ # see all events in a particular product area, as well as in rare cases
88
+ # be used across product codes to show events across the system.
89
+ #
90
+ # Bias this name toward the particular flow or feature being used,
91
+ # e.g. Scheduling
92
+ # @param name [String] Short plain text description of the event that is
93
+ # being recorded. For example, “Hold booked”
94
+ #
95
+ # Follow the format: “Object action [descriptor]”. For example,
96
+ # “Hold converted to appointment” or “Appointment deleted”
97
+ # @param user [Hash] A simple hash containing relevant user information.
98
+ # **This information should never contain any PII or PHI**. There does,
99
+ # however, need to be an `id` property to uniquely identify the user.
100
+ #
101
+ # One of the following (in order):
102
+ #
103
+ # 1. Patient/User
104
+ # 2. If it does not relate to a patient, Provider
105
+ # 3. If it does not relate to a patient or provider, Insurer
106
+ # 4. If it does not relate to a patient, provider, or insurer, use external system ID information
107
+ # - E.g. for Zocdoc, this maybe the primary key for their API key
108
+ # - Do not use an API key as an identifier, instead use another key like the id in our database for that key
109
+ #
110
+ # If a different user (like an ops person) took an action, put an ID
111
+ # for them under `agent_id` in the metadata
112
+ # @param user_type [Reporting::UserType] The type of user we are reporting
113
+ # this event for
114
+ # @param trigger [Reporting::Trigger] What triggered this event to be
115
+ # recoreded (e.g. a page view, or an interaction).
116
+ # @param metadata [Hash] Metadata to report alongside the analytics event.
117
+ # **This should not contain any PII or PHI*
118
+ #
119
+ # @example
120
+ # PathReporting.analytics.record(
121
+ # product_code: Constants::ANALYTICS_PRODUCT_CODE,
122
+ # product_area: Constants::ANALYTICS_PRODUCT_AREA_MATCHING,
123
+ # name: 'Preferred provider multiple valid matches',
124
+ # user: @contact.analytics_friendly_hash,
125
+ # user_type: PathReporting::UserType::PATIENT,
126
+ # trigger: PathReporting::Trigger.PAGE_VIEW,
127
+ # metadata: analytics_metadata,
128
+ # )
129
+ # @example Validating Successful Reporting
130
+ # analytics_reported = PathReporting.analytics.record(
131
+ # product_code: Constants::ANALYTICS_PRODUCT_CODE,
132
+ # product_area: Constants::ANALYTICS_PRODUCT_AREA_MATCHING,
133
+ # name: 'No preferred provider',
134
+ # user: @contact.analytics_friendly_hash,
135
+ # user_type: PathReporting::UserType::PATIENT,
136
+ # trigger: PathReporting::Trigger.PAGE_VIEW,
137
+ # )
138
+ #
139
+ # analytics_reported.each do |status|
140
+ # Rails.logger.warn("#{status.reporter} failed") unless status.result.nil?
141
+ # end
142
+ #
143
+ # @raise [StandardError] if no user is provided or user does not have id
144
+ # @raise [StandardError] if user_type is not a Reporting::UserType
145
+ # @raise [StandardError] if trigger is not a Reporting::Trigger
146
+ #
147
+ # @return [Array] An array of result hashes with two keys:
148
+ #
149
+ # - `reporter` [String] the analytics reporter the result is for
150
+ # - `result` [nil | StandardError] what the result of running this
151
+ # reporter was. If it did not run, it will always be `nil`
152
+ #
153
+ # @see https://docs.google.com/document/d/1axnk1EkKCb__sxtvMomrPNup3wsviDOAefQWwXU3Z3U/edit# Analytics Guide
154
+ # @see Reporting::UserType
155
+ # @see Reporting::Trigger
156
+ def record(
157
+ product_code:,
158
+ product_area:,
159
+ name:,
160
+ user:,
161
+ user_type: UserType::PATIENT,
162
+ trigger: Trigger::INTERACTION,
163
+ metadata: {}
164
+ )
165
+ throw Error("No user provided when reporting analytics") if !user || !user[:id]
166
+ throw Error("Invalid UserType #{user_type}") unless UserType.valid?(user_type)
167
+ throw Error("Invalid Trigger #{trigger}") unless Trigger.valid?(trigger)
168
+
169
+ clients.map do |reporter, client|
170
+ {
171
+ reporter: reporter.to_s,
172
+ result: send_event_to_client(client, {
173
+ name: format_event_name(product_code: product_code, product_area: product_area, name: name),
174
+ user: user,
175
+ user_type: user_type,
176
+ metadata: metadata,
177
+ trigger: trigger
178
+ })
179
+ }
180
+ end
181
+ end
182
+
183
+ # Wraps sending to the client in a rescue so we can report results
184
+ # without causing other reporters to not run
185
+ # @private
186
+ def send_event_to_client(client, event)
187
+ client&.record(**event)
188
+ nil
189
+ rescue StandardError => e
190
+ e
191
+ end
192
+
193
+ # Primarily used for testing
194
+ # @private
195
+ def reset!
196
+ @clients = nil
197
+ end
198
+ end
199
+ end
200
+ end
@@ -4,6 +4,7 @@ require_relative "analytics/configuration"
4
4
 
5
5
  module Path
6
6
  module Reporting
7
+ # Global configuration for all reporting sub-modules
7
8
  # @!attribute [r] analytics
8
9
  # @return [Analytics::Configuration] the configuration for analytics reporting
9
10
  class Configuration
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Path
4
+ module Reporting
5
+ # The trigger or cause of reporting events
6
+ class Trigger
7
+ # Interaction: When a direct intentional user action is the cause of
8
+ # this event that is not a simple navigation. E.g. Submitted form or
9
+ # changed password
10
+ INTERACTION = "Interaction"
11
+ # Page view: When the event was an indirect result of viewing something.
12
+ # E.g. auto-assigning a provider or appointment because the user has an
13
+ # existing provider already
14
+ # @note Because of usage limits, we do not want to record page views
15
+ # as a separate action, this is only for indirect consequences that
16
+ # result in a change in something either for the user or for our
17
+ # systems
18
+ PAGE_VIEW = "Page view"
19
+ # Automation: Some automation or tool was the cause of this event
20
+ AUTOMATION = "Automation"
21
+
22
+ class << self
23
+ # @private
24
+ def triggers
25
+ [
26
+ INTERACTION,
27
+ PAGE_VIEW,
28
+ AUTOMATION
29
+ ]
30
+ end
31
+
32
+ # Check if a given item is a valid Trigger
33
+ # @param maybe_trigger [Any] item to check
34
+ def valid?(maybe_trigger)
35
+ triggers.includes? maybe_trigger
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Path
4
+ module Reporting
5
+ # User types that data may be recorded for or on
6
+ class UserType
7
+ # Patient or potential patient
8
+ PATIENT = "Patient"
9
+ # Provider, which can be any sub-type (e.g. therapist)
10
+ PROVIDER = "Provider"
11
+ # Insurer, not currently in-use
12
+ INSURER = "Insurer"
13
+ # Operator or any internal non-developer
14
+ OPERATOR = "Operator"
15
+ # Developer; mostly relevant for backfills or manual intervention
16
+ DEVELOPER = "Developer"
17
+ # System, either first-party or third-party
18
+ SYSTEM = "System"
19
+
20
+ class << self
21
+ # @private
22
+ def types
23
+ [
24
+ PATIENT,
25
+ PROVIDER,
26
+ INSURER,
27
+ OPERATOR,
28
+ DEVELOPER,
29
+ SYSTEM
30
+ ]
31
+ end
32
+
33
+ # Check if a given item is a valid UserType
34
+ # @param maybe_type [Any] item to check
35
+ def valid?(maybe_type)
36
+ types.include? maybe_type
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Path
4
4
  module Reporting
5
- VERSION = "0.1.1"
5
+ # Current version of the module
6
+ VERSION = "0.1.4"
6
7
  end
7
8
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "analytics/analytics"
4
- require_relative "configuration"
3
+ require_relative "reporting/analytics"
4
+ require_relative "reporting/configuration"
5
5
  require_relative "reporting/version"
6
6
 
7
7
  # Path is just a wrapper module so we can group any path specific gems under
@@ -12,6 +12,7 @@ module Path
12
12
  # meant to be a one-stop shop for all of our ruby code to import and have
13
13
  # everything they need to track all the things
14
14
  module Reporting
15
+ # @private
15
16
  class Error < StandardError; end
16
17
 
17
18
  class << self
data/lib/path.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # defines the Path namespace
4
+ module Path
5
+ end
data/reporting.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "lib/reporting/version"
3
+ require_relative "lib/path/reporting/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "path-reporting"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: path-reporting
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexis Hushbeck
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-04-28 00:00:00.000000000 Z
11
+ date: 2022-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: amplitude-api
@@ -47,20 +47,23 @@ extra_rdoc_files: []
47
47
  files:
48
48
  - ".rspec"
49
49
  - ".rubocop.yml"
50
+ - ".tool-versions"
50
51
  - ".yardopts"
51
52
  - Gemfile
52
53
  - Gemfile.lock
54
+ - LICENSE.md
53
55
  - README.md
54
56
  - Rakefile
55
- - lib/analytics/analytics.rb
56
- - lib/analytics/channels/amplitude.rb
57
- - lib/analytics/channels/console.rb
58
- - lib/analytics/configuration.rb
59
- - lib/configuration.rb
60
- - lib/reporting.rb
61
- - lib/reporting/version.rb
62
- - lib/types/trigger.rb
63
- - lib/types/user_type.rb
57
+ - lib/path.rb
58
+ - lib/path/reporting.rb
59
+ - lib/path/reporting/analytics.rb
60
+ - lib/path/reporting/analytics/amplitude.rb
61
+ - lib/path/reporting/analytics/configuration.rb
62
+ - lib/path/reporting/analytics/console.rb
63
+ - lib/path/reporting/configuration.rb
64
+ - lib/path/reporting/types/trigger.rb
65
+ - lib/path/reporting/types/user_type.rb
66
+ - lib/path/reporting/version.rb
64
67
  - reporting.gemspec
65
68
  - sig/reporting.rbs
66
69
  homepage: https://github.com/pathccm/reporting
@@ -1,92 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "channels/amplitude"
4
- require_relative "channels/console"
5
- require_relative "../types/trigger"
6
- require_relative "../types/user_type"
7
-
8
- module Path
9
- module Reporting
10
- class Analytics
11
- def initialize(config)
12
- @config = config
13
- end
14
-
15
- # Get clients for reporting events based on environment
16
- def clients
17
- if @clients.nil?
18
- @clients = {
19
- amplitude: setup_amplitude,
20
- console: setup_console
21
- }
22
- end
23
-
24
- @clients
25
- end
26
-
27
- def setup_amplitude
28
- # Amplitude reporting for metrics
29
- return Path::Reporting::Analytics::Channels::Amplitude.new @config if @config.amplitude_enabled?
30
-
31
- nil
32
- end
33
-
34
- def setup_console
35
- return Path::Reporting::Analytics::Channels::Console.new @config if @config.console_enabled?
36
-
37
- nil
38
- end
39
-
40
- def format_event_name(product_code:, product_area:, name:)
41
- formatted_code = product_code.gsub(" ", "_").upcase
42
- formatted_area = product_area.split(" ").each(&:capitalize!).join("")
43
- formatted_desc = name.capitalize.gsub(" ", "_")
44
- "#{formatted_code}_#{formatted_area}_#{formatted_desc}"
45
- end
46
-
47
- def record(
48
- product_code:,
49
- product_area:,
50
- name:,
51
- user:,
52
- user_type: UserType.PATIENT,
53
- trigger: Trigger.INTERACTION,
54
- metadata: {}
55
- )
56
- throw Error("No user provided when reporting analytics") unless user
57
- throw Error("Invalid UserType #{user_type}") unless UserType.valid?(user_type)
58
- throw Error("Invalid Trigger #{trigger}") unless Trigger.valid?(trigger)
59
-
60
- all_succeeded = true
61
- exceptions = {}
62
- clients.each do |_reporter, client|
63
- next if client.nil?
64
-
65
- begin
66
- event_name = format_event_name(product_code: product_code, product_area: product_area, name: name)
67
- client.record(
68
- name: event_name,
69
- user: user,
70
- user_type: user_type,
71
- metadata: metadata,
72
- trigger: trigger
73
- )
74
- exceptions[client.channel_name] = nil
75
- rescue StandardError => e
76
- all_succeeded = false
77
- exceptions[client.channel_name] = e
78
- end
79
- end
80
-
81
- {
82
- all_succeeded: all_succeeded,
83
- exceptions: exceptions
84
- }
85
- end
86
-
87
- def reset!
88
- @clients = nil
89
- end
90
- end
91
- end
92
- end
@@ -1,115 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "amplitude-api"
4
-
5
- # See https://developers.amplitude.com/docs/http-api-v2#schemauploadrequestbody
6
- API_METADATA_TO_ELEVATE = [
7
- "device_id",
8
- "app_version",
9
- "platform",
10
- "os_name",
11
- "os_version",
12
- "device_brand",
13
- "device_manufacturer",
14
- "device_model",
15
- "carrier",
16
- "country",
17
- "region",
18
- # We do not elevate city because at that level it is possibly PII
19
- "dma",
20
- "language",
21
- "price",
22
- "quantity",
23
- "revenue",
24
- "productId",
25
- "revenueType",
26
- # We do not elevate lat/long/IP because it is PII (IP at least for analytics)
27
- "event_id",
28
- "session_id",
29
- "insert_id",
30
- "plan"
31
- ].freeze
32
-
33
- # Amplitue is not HIPAA compliant, so there are a number of PII things we want
34
- # to make sure to filter out. These are keys (all lowercase) that are things
35
- # we want to filter. Lowercased matching keys in data are obfuscated.
36
- DISALLOWED_METADATA_PII_KEYS = %w[
37
- email
38
- name
39
- first_name
40
- firstname
41
- last_name
42
- lastname
43
- zip
44
- ssn
45
- dob
46
- address
47
- phone
48
- contactinfo
49
- patient_chart_id
50
- ].freeze
51
-
52
- # Don't clean to infinity (and beyond)
53
- MAX_METADATA_DEPTH = 4
54
-
55
- module Path
56
- module Reporting
57
- class Analytics
58
- module Channels
59
- # Amplitude analytics is our primary analytics channel for production
60
- class Amplitude
61
- attr_reader :channel_name
62
-
63
- def initialize(config)
64
- @channel_name = "Amplitude"
65
- @config = config
66
-
67
- config.amplitude_config.each do |key, value|
68
- AmplitudeAPI.config.instance_variable_set("@#{key}", value)
69
- end
70
- end
71
-
72
- def record(name:, user:, user_type:, trigger:, metadata: {})
73
- user = user.dup
74
- metadata = metadata.dup
75
- user[:user_type] = user_type
76
- metadata[:trigger] = trigger
77
-
78
- event_props = {
79
- user_id: user[:id].to_s,
80
- user_properties: scrub_pii(user),
81
- event_type: name,
82
- event_properties: scrub_pii(metadata)
83
- }
84
- API_METADATA_TO_ELEVATE.each do |key|
85
- event_props[key] = metadata[key] if metadata.key? key
86
- end
87
-
88
- AmplitudeAPI.track AmplitudeAPI::Event.new event_props
89
- end
90
-
91
- def scrub_pii(data, depth: 0)
92
- return "[DATA]" if depth > MAX_METADATA_DEPTH
93
-
94
- case data
95
- when Hash
96
- data = data.dup
97
- # Replace any disallowed keys, and recurse for allowed values
98
- data.each do |key, val|
99
- data[key] = if DISALLOWED_METADATA_PII_KEYS.include? key.to_s
100
- "XXXXXXXX"
101
- else
102
- scrub_pii(val, depth: depth + 1)
103
- end
104
- end
105
- when Array
106
- return data.map { |item| scrub_pii(item, depth: depth + 1) }
107
- end
108
-
109
- data
110
- end
111
- end
112
- end
113
- end
114
- end
115
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Path
4
- module Reporting
5
- class Analytics
6
- module Channels
7
- class Console
8
- attr_reader :channel_name
9
-
10
- def initialize(config)
11
- @channel_name = "Console"
12
- @config = config
13
- end
14
-
15
- def record(name:, user:, user_type:, trigger:, metadata: {})
16
- @config.console_logger.info("[#{trigger}]:#{name} - #{user.inspect} (#{user_type}) #{metadata.nil? ? "" : metadata.inspect}")
17
- end
18
- end
19
- end
20
- end
21
- end
22
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Path
4
- module Reporting
5
- class Analytics
6
- class Configuration
7
- attr_accessor :console_enabled
8
- attr_reader :amplitude_config, :console_logger
9
-
10
- def initialize
11
- @console_enabled = false
12
- @console_logger = nil
13
- @amplitude_enabled = false
14
- @amplitude_config = nil
15
- end
16
-
17
- def logger=(logger)
18
- @console_enabled = !logger.nil?
19
- @console_logger = logger
20
- end
21
-
22
- alias console= logger=
23
-
24
- def console_enabled?
25
- @console_enabled && @console_logger
26
- end
27
-
28
- def amplitude_config=(conf)
29
- @amplitude_enabled = !conf.nil?
30
- @amplitude_config = conf
31
- end
32
-
33
- alias amplitude= amplitude_config=
34
-
35
- def amplitude_enabled?
36
- @amplitude_enabled && @amplitude_config
37
- end
38
- end
39
- end
40
- end
41
- end
data/lib/types/trigger.rb DELETED
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Path
4
- module Reporting
5
- class Trigger
6
- class << self
7
- INTERACTION = "Interaction"
8
- PAGE_VIEW = "Page view"
9
- AUTOMATION = "Automation"
10
-
11
- def triggers
12
- [
13
- INTERACTION,
14
- PAGE_VIEW,
15
- AUTOMATION
16
- ]
17
- end
18
-
19
- def valid?(maybe_trigger)
20
- triggers.includes? maybe_trigger
21
- end
22
- end
23
- end
24
- end
25
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Path
4
- module Reporting
5
- class UserType
6
- class << self
7
- PATIENT = "Patient"
8
- PROVIDER = "Provider"
9
- INSURER = "Insurer"
10
- OPERATOR = "Operator"
11
- DEVELOPER = "Developer"
12
- SYSTEM = "System"
13
-
14
- def types
15
- [
16
- PATIENT,
17
- PROVIDER,
18
- INSURER,
19
- OPERATOR,
20
- DEVELOPER,
21
- SYSTEM
22
- ]
23
- end
24
-
25
- def valid?(maybe_type)
26
- types.includes? maybe_type
27
- end
28
- end
29
- end
30
- end
31
- end