path-reporting 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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