gitlab_internal_events_cli 0.0.1 → 0.1.1

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: 53b38b01e7214b61bb0a8768a926f2a335c7fcdd509c23879aa00627a15b9e6d
4
- data.tar.gz: 7a23d46213056876709d6c6c1d77b068c94bf8b54a4887faec103747718d2b0f
3
+ metadata.gz: 488d06f3e32cbc1e6c1a32bb6cf214181762cda5078069f725892cd9d65a523b
4
+ data.tar.gz: d05a91f4a982dd1cc3941a0d61f4333054cb8cd6f2d9f5be69bd940926be3a4b
5
5
  SHA512:
6
- metadata.gz: e869509a120eeb35c09557cdc96c7ce6313338a95ef7dc59b7110202e55719460e01abf1e1f497f0eab3cd2df9a860eb3932f0fdd4307cb350d2cfa8976dc460
7
- data.tar.gz: 11afd1175a2873484df7af82f9071610811bcd07e69aa0438eab73dc97bb94ac4008478e78af4b6b50234b0f8a943c6a4a71c9ecd17358816c8c826e3fec9e3e
6
+ metadata.gz: 123411c52cbd00083d1bef970f4c2361c4c4b8ddebb06f4a736ddf4f11188ebbfbc4cb9cf9141ac17099bfd75911caf29290244f30c31ba62147271ce7fcdcff
7
+ data.tar.gz: bcf890d1283412da086d3bdee6eb6b91d8b49ed3a2f0c6bbe9a85fa433dcf8e6bf085ebba204831fc85be132f88c264bd979537b496bf90789de89c37af7def4
data/README.md CHANGED
@@ -14,19 +14,7 @@ This gem extracts the Internal Events CLI from the GitLab monorepo, making it an
14
14
 
15
15
  ## Installation
16
16
 
17
- Add this line to your application's Gemfile:
18
-
19
- ```ruby
20
- gem 'gitlab_internal_events_cli', path: 'path/to/this/repo'
21
- ```
22
-
23
- And then execute:
24
-
25
- ```bash
26
- bundle install
27
- ```
28
-
29
- Or install it yourself as:
17
+ Install the gem globally as:
30
18
 
31
19
  ```bash
32
20
  gem install gitlab_internal_events_cli
@@ -36,18 +24,34 @@ gem install gitlab_internal_events_cli
36
24
 
37
25
  ### Running the CLI
38
26
 
39
- From the root of a GitLab project (or any project with the expected directory structure):
27
+ From the root of a project:
40
28
 
41
29
  ```bash
42
- gitlab-internal-events-cli
30
+ gem exec gitlab_internal_events_cli
43
31
  ```
44
32
 
45
33
  The CLI provides an interactive menu to:
46
34
  - **New Event** - Define a new event to track specific scenarios
47
- - **New Metric** - Define metrics that count events over time
35
+ - **New Metric** - Define metrics that count events over time, or that are derived from the database
48
36
  - **View Usage** - See code examples for existing events
49
37
  - **Help/Flow Advisor** - Get guidance on which tool to use
50
38
 
39
+ ### Database metrics
40
+
41
+ Selecting **Database** at the metric-type prompt scaffolds three files in one go:
42
+
43
+ - A YAML metric definition under `config/metrics/counts_*/` (or `ee/config/metrics/counts_*/` for non-`free` tiers).
44
+ - A Ruby instrumentation class under `lib/gitlab/usage/metrics/instrumentations/` (or `ee/lib/...`).
45
+ - A matching RSpec file under `spec/lib/gitlab/usage/metrics/instrumentations/` (or `ee/spec/lib/...`)
46
+ using the `a correct instrumented metric value and query` shared example.
47
+
48
+ You will be prompted for the instrumentation class name and the operation
49
+ (`count`, `distinct_count`, `estimate_batch_distinct_count`, `sum`, `average`).
50
+ After generation, fill in the ActiveRecord relation in the class and the
51
+ `expected_value` / `expected_query` in the spec. See the
52
+ [database metrics documentation](https://docs.gitlab.com/development/internal_analytics/metrics/metrics_instrumentation/#database-metrics)
53
+ for details.
54
+
51
55
  ### Configuration
52
56
 
53
57
  The CLI can be configured via a `.gitlab_internal_events_cli.yml` file in your project root:
@@ -90,7 +94,7 @@ The CLI expects the following structure (configurable):
90
94
 
91
95
  ```
92
96
  project/
93
- ├── VERSION # Milestone detection
97
+ ├── VERSION # Milestone detection (optional)
94
98
  ├── config/
95
99
  │ ├── events/*.yml # Event definitions
96
100
  │ ├── metrics/
@@ -151,14 +155,6 @@ Alternatively, run directly from the repo without installing the gem:
151
155
  bundle exec exe/gitlab-internal-events-cli
152
156
  ```
153
157
 
154
- ## Migration from GitLab Monorepo
155
-
156
- This gem was extracted from the original CLI at `gitlab/scripts/internal_events/cli.rb`. Key changes:
157
-
158
- 1. **Configuration System**: All hardcoded paths are now configurable
159
- 2. **Standalone Gem**: Can be used outside the GitLab monorepo with proper configuration
160
- 3. **Module Renamed**: `InternalEventsCli` → `GitlabInternalEventsCli`
161
-
162
158
  ## License
163
159
 
164
160
  The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -14,6 +14,7 @@ module GitlabInternalEventsCli
14
14
  HttpCache.preload!
15
15
 
16
16
  cli.say feedback_notice
17
+ check_for_updates
17
18
  cli.say instructions
18
19
 
19
20
  task = cli.select('What would you like to do?', **select_opts) do |menu|
@@ -39,6 +40,34 @@ module GitlabInternalEventsCli
39
40
  end
40
41
  end
41
42
 
43
+ private
44
+
45
+ def check_for_updates
46
+ checker = VersionChecker.new
47
+ return unless checker.update_available?
48
+
49
+ cli.say format_warning(
50
+ "\nUpdate available! Current version: #{VERSION}, Latest version: #{checker.latest_version}\n"
51
+ )
52
+
53
+ return unless cli.yes?(
54
+ 'Would you like to update now?',
55
+ **yes_no_opts
56
+ )
57
+
58
+ cli.say format_info("\nUpdating #{VersionChecker::GEM_NAME}...\n")
59
+
60
+ if checker.self_update!
61
+ cli.say format_selection("\nUpdate successful! Please restart the CLI to use the new version.\n")
62
+ exit 0
63
+ else
64
+ cli.say format_error("\nUpdate failed. You can update manually with: " \
65
+ "gem install #{VersionChecker::GEM_NAME}\n")
66
+ end
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
42
71
  def instructions
43
72
  cli.say <<~TEXT.freeze
44
73
  #{format_info('INSTRUCTIONS:')}
@@ -42,6 +42,12 @@ module GitlabInternalEventsCli
42
42
  @event_schema_url = DEFAULT_EVENT_SCHEMA_URL
43
43
  @metric_schema_url = DEFAULT_METRIC_SCHEMA_URL
44
44
  @stages_url = DEFAULT_STAGES_URL
45
+ @python_filepaths = [
46
+ 'poetry.lock',
47
+ 'pyproject.toml',
48
+ '.python-version',
49
+ 'requirements.txt'
50
+ ]
45
51
  end
46
52
 
47
53
  def milestone
@@ -68,6 +74,10 @@ module GitlabInternalEventsCli
68
74
  apply_config(config)
69
75
  end
70
76
 
77
+ def python_project?
78
+ @python_filepaths.any? { |f| File.exist?(absolute_path(f)) }
79
+ end
80
+
71
81
  private
72
82
 
73
83
  def apply_config(config)
@@ -27,6 +27,8 @@ module GitlabInternalEventsCli
27
27
  'Save files'
28
28
  ].freeze
29
29
 
30
+ DUO_EVENT_CLASS = 'duo'
31
+
30
32
  IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.map { |k| k.join(', ') }.max_by(&:length)}]".length
31
33
 
32
34
  attr_reader :cli, :event
@@ -237,7 +239,7 @@ module GitlabInternalEventsCli
237
239
  menu.choice 'No - this event is not related to AI/Duo features', :none
238
240
  end
239
241
 
240
- event.classification = 'duo' if classification_choice == :duo
242
+ event.classification = DUO_EVENT_CLASS if classification_choice == :duo
241
243
  end
242
244
 
243
245
  def create_event_file
@@ -255,6 +257,7 @@ module GitlabInternalEventsCli
255
257
 
256
258
  #{outcome || ' No files saved.'}
257
259
 
260
+ #{ai_tracking_suggestion}#{python_codebase_advice}
258
261
  #{divider}
259
262
 
260
263
  Do you need to create a metric? Probably!
@@ -301,6 +304,22 @@ module GitlabInternalEventsCli
301
304
  cli.say feedback_notice
302
305
  end
303
306
  end
307
+
308
+ def ai_tracking_suggestion
309
+ return unless duo_event?
310
+
311
+ generate_ai_event_suggestion(event.action)
312
+ end
313
+
314
+ def python_codebase_advice
315
+ return unless GitlabInternalEventsCli.configuration.python_project?
316
+
317
+ generate_python_advice
318
+ end
319
+
320
+ def duo_event?
321
+ event.classification == DUO_EVENT_CLASS
322
+ end
304
323
  end
305
324
  end
306
325
  end
@@ -16,9 +16,14 @@ module GitlabInternalEventsCli
16
16
 
17
17
  def run
18
18
  return use_case_error unless goal_is_tracking_usage?
19
- return use_case_error unless usage_trackable_with_internal_events?
20
19
 
21
- event_already_tracked? ? proceed_to_metric_definition : proceed_to_event_definition
20
+ if usage_trackable_with_internal_events?
21
+ event_already_tracked? ? proceed_to_metric_definition : proceed_to_event_definition
22
+ elsif usage_trackable_from_database?
23
+ proceed_to_database_metric_definition
24
+ else
25
+ use_case_error
26
+ end
22
27
  end
23
28
 
24
29
  private
@@ -44,6 +49,19 @@ module GitlabInternalEventsCli
44
49
  )
45
50
  end
46
51
 
52
+ def usage_trackable_from_database?
53
+ new_page!
54
+
55
+ cli.say format_info("Got it! Let's check whether a database metric fits.\n")
56
+ cli.say DATABASE_METRIC_EXAMPLES
57
+
58
+ cli.yes?(
59
+ 'Can the data for this metric be derived from a database query (e.g. a count, sum, ' \
60
+ 'distinct count or average over an ActiveRecord relation)?',
61
+ **yes_no_opts
62
+ )
63
+ end
64
+
47
65
  def event_already_tracked?
48
66
  new_page!
49
67
 
@@ -81,6 +99,20 @@ module GitlabInternalEventsCli
81
99
  EventDefiner.new(cli).run
82
100
  end
83
101
 
102
+ def proceed_to_database_metric_definition
103
+ new_page!
104
+
105
+ cli.say format_info("Great! The next step is adding a database metric.\n")
106
+ cli.say(
107
+ 'When prompted for the metric type, choose ' \
108
+ "#{format_info('Database')} to scaffold the YAML, Ruby class, and spec files.\n"
109
+ )
110
+
111
+ return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?'))
112
+
113
+ MetricDefiner.new(cli).run
114
+ end
115
+
84
116
  def not_ready_error(description)
85
117
  cli.say "\nNo problem! When you're ready, run the CLI & select '#{description}'\n"
86
118
  cli.say feedback_notice
@@ -92,13 +92,6 @@ module GitlabInternalEventsCli
92
92
  def prompt_for_configuration(type)
93
93
  case type
94
94
  when :database_metric
95
- # CLI doesn't load rails, so perform a simplified string <-> boolean check
96
- if [nil, 'false', '0'].include? ENV['ENABLE_DATABASE_METRIC']
97
- cli.error DATABASE_METRIC_NOTICE
98
- cli.say feedback_notice
99
- return
100
- end
101
-
102
95
  db_metric_definer = Subflows::DatabaseMetricDefiner.new(cli)
103
96
  db_metric_definer.run
104
97
  @metric = db_metric_definer.metric
@@ -224,7 +217,31 @@ module GitlabInternalEventsCli
224
217
  cli.say format_prompt(format_subheader('SAVING FILE', metric.description))
225
218
  cli.say "\n"
226
219
 
227
- prompt_to_save_file(metric.file_path, metric.formatted_output)
220
+ yaml_outcome = prompt_to_save_file(metric.file_path, metric.formatted_output)
221
+
222
+ return yaml_outcome unless metric.database_metric?
223
+
224
+ instrumentation_outcome = save_instrumentation_class_files
225
+
226
+ [yaml_outcome, instrumentation_outcome].compact.join("\n")
227
+ end
228
+
229
+ # For database metrics, also scaffold the Ruby instrumentation class
230
+ # and a matching spec file. These mirror the output of the upstream
231
+ # `rails generate gitlab:usage_metric` generator.
232
+ def save_instrumentation_class_files
233
+ targets = [
234
+ [metric.instrumentation_class_file_path, metric.instrumentation_class_content],
235
+ [metric.instrumentation_class_spec_file_path, metric.instrumentation_class_spec_content]
236
+ ]
237
+
238
+ targets.filter_map do |filepath, content|
239
+ cli.say "\n"
240
+ cli.say format_prompt(format_subheader('SAVING FILE', filepath))
241
+ cli.say "\n"
242
+
243
+ prompt_to_save_file(filepath, content)
244
+ end.join("\n")
228
245
  end
229
246
 
230
247
  def show_all_metric_paths(metric)
@@ -248,6 +265,7 @@ module GitlabInternalEventsCli
248
265
 
249
266
  event_metric_message = "\n Have you instrumented the application code to trigger the event yet? " \
250
267
  "View usage examples to easily copy/paste implementation!\n"
268
+ database_metric_message = "\n#{format_prefix(' ', DATABASE_INSTRUMENTATION_FILES_NEXT_STEPS)}"
251
269
  cli.say <<~TEXT
252
270
  #{divider}
253
271
  #{format_info('Done with metric definitions!')}
@@ -255,6 +273,7 @@ module GitlabInternalEventsCli
255
273
  #{outcome}
256
274
  #{divider}
257
275
  #{event_metric_message if metric.event_metric?}
276
+ #{database_metric_message if metric.database_metric?}
258
277
  Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau
259
278
  Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed
260
279
  Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))}
@@ -339,23 +358,34 @@ module GitlabInternalEventsCli
339
358
 
340
359
  return unless name_reason
341
360
 
342
- default_name = metric.key.value
343
- display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m")
361
+ suggested_name = suggested_metric_name
362
+ default_name = suggested_name || metric.key.value
363
+ display_name = metric.key.value(suggested_name || "\e[0m[REPLACE ME]\e[36m")
344
364
  empty_name = metric.key.value('')
345
365
  max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length
346
- help_tokens = { name: default_name, count: max_length }
366
+ help_tokens = {
367
+ name: default_name,
368
+ count: max_length,
369
+ class_name: metric.instrumentation_class.to_s,
370
+ suggested_name: suggested_name || '(none — please enter a snake_case name)'
371
+ }
347
372
 
348
373
  cli.say <<~TEXT
349
374
 
350
- #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text}
375
+ #{input_opts[:prefix]} #{name_reason[:text]} How should we reference this metric? #{input_required_text}
351
376
 
352
377
  ID: #{format_info(display_name)}
353
378
  Filename: #{format_info(display_name)}#{format_info('.yml')}
354
379
 
380
+ #{metric_name_format_hint(suggested_name)}
355
381
  TEXT
356
382
 
357
383
  metric.key = prompt_for_text(' Replace with: ', multiline: true) do |q|
358
384
  q.required true
385
+ # Use #default (not #value) so the suggested name is accepted on
386
+ # blank Enter but does NOT prefill the input buffer — prefilling
387
+ # would concatenate the user's typing onto the suggestion.
388
+ q.default suggested_name if suggested_name
359
389
  q.messages[:required?] = name_reason[:help] % help_tokens
360
390
  q.messages[:valid?] = NAME_ERROR % help_tokens
361
391
  q.validate lambda { |input|
@@ -366,6 +396,40 @@ module GitlabInternalEventsCli
366
396
  end
367
397
  end
368
398
 
399
+ # Inline hint printed alongside the metric name prompt. For database
400
+ # metrics this is critical context: users have just entered a CamelCase
401
+ # instrumentation class name (e.g. `CountIssuesMetric`), and without
402
+ # this hint they typically retype it here and hit the snake_case
403
+ # validation.
404
+ def metric_name_format_hint(suggested_name)
405
+ return '' unless metric.database_metric?
406
+
407
+ suggestion_line =
408
+ if suggested_name
409
+ " Suggested: #{format_info(suggested_name)} (press Enter to accept)"
410
+ else
411
+ ' Format: snake_case — lowercase letters, numbers, and underscores only'
412
+ end
413
+
414
+ <<~HINT.chomp
415
+ #{format_help('Use snake_case here — this is the metric key/filename, not the Ruby class name.')}
416
+ #{suggestion_line}
417
+ HINT
418
+ end
419
+
420
+ # For database metrics, propose a snake_case default derived from
421
+ # the instrumentation class. Returns nil for event metrics and when
422
+ # the derived value is empty or not unique in the project.
423
+ def suggested_metric_name
424
+ return unless metric.database_metric?
425
+
426
+ suggestion = metric.default_name_from_instrumentation_class
427
+ return if suggestion.nil? || suggestion.empty?
428
+ return if conflicting_key_path?(metric.key.value(suggestion))
429
+
430
+ suggestion
431
+ end
432
+
369
433
  # Helper for #prompt_for_description
370
434
  def name_requirement_reason
371
435
  if metric.filters.assigned?
@@ -12,6 +12,22 @@ module GitlabInternalEventsCli
12
12
  'property' => "'string'",
13
13
  'value' => '72'
14
14
  }.freeze
15
+
16
+ CHOICES = [
17
+ { name: '1. ruby/rails', value: :rails },
18
+ { name: '2. rspec', value: :rspec },
19
+ { name: '3. python', value: :python },
20
+ { name: '4. pytest', value: :pytest },
21
+ { name: '5. javascript (vue)', value: :vue },
22
+ { name: '6. javascript (plain)', value: :js },
23
+ { name: '7. vue template', value: :vue_template },
24
+ { name: '8. haml', value: :haml },
25
+ { name: '9. Manual testing in GDK', value: :gdk },
26
+ { name: '10. Data verification in Tableau', value: :tableau },
27
+ { name: '11. View examples for a different event', value: :other_event },
28
+ { name: '12. Exit', value: :exit }
29
+ ].freeze
30
+
15
31
  DEFAULT_PROPERTY_VALUE = "'custom_value'"
16
32
 
17
33
  attr_reader :cli, :event
@@ -43,25 +59,12 @@ module GitlabInternalEventsCli
43
59
  end
44
60
 
45
61
  def prompt_for_usage_location(default = '1. ruby/rails')
46
- choices = [
47
- { name: '1. ruby/rails', value: :rails },
48
- { name: '2. rspec', value: :rspec },
49
- { name: '3. javascript (vue)', value: :vue },
50
- { name: '4. javascript (plain)', value: :js },
51
- { name: '5. vue template', value: :vue_template },
52
- { name: '6. haml', value: :haml },
53
- { name: '7. Manual testing in GDK', value: :gdk },
54
- { name: '8. Data verification in Tableau', value: :tableau },
55
- { name: '9. View examples for a different event', value: :other_event },
56
- { name: '10. Exit', value: :exit }
57
- ]
58
-
59
62
  usage_location = cli.select(
60
63
  'Select a use-case to view examples for:',
61
- choices,
64
+ CHOICES,
62
65
  **select_opts,
63
66
  **filter_opts,
64
- per_page: 10
67
+ per_page: CHOICES.count
65
68
  ) do |menu|
66
69
  menu.default default
67
70
  end
@@ -73,24 +76,30 @@ module GitlabInternalEventsCli
73
76
  when :rspec
74
77
  rspec_examples
75
78
  prompt_for_usage_location('2. rspec')
79
+ when :python
80
+ python_examples
81
+ prompt_for_usage_location('3. python')
82
+ when :pytest
83
+ pytest_examples
84
+ prompt_for_usage_location('4. pytest')
76
85
  when :haml
77
86
  haml_examples
78
- prompt_for_usage_location('6. haml')
87
+ prompt_for_usage_location('8. haml')
79
88
  when :js
80
89
  js_examples
81
- prompt_for_usage_location('4. javascript (plain)')
90
+ prompt_for_usage_location('6. javascript (plain)')
82
91
  when :vue
83
92
  vue_examples
84
- prompt_for_usage_location('3. javascript (vue)')
93
+ prompt_for_usage_location('5. javascript (vue)')
85
94
  when :vue_template
86
95
  vue_template_examples
87
- prompt_for_usage_location('5. vue template')
96
+ prompt_for_usage_location('7. vue template')
88
97
  when :gdk
89
98
  gdk_examples
90
- prompt_for_usage_location('7. Manual testing in GDK')
99
+ prompt_for_usage_location('9. Manual testing in GDK')
91
100
  when :tableau
92
101
  service_ping_dashboard_examples
93
- prompt_for_usage_location('8. Data verification in Tableau')
102
+ prompt_for_usage_location('10. Data verification in Tableau')
94
103
  when :other_event
95
104
  self.class.new(cli).run
96
105
  when :exit
@@ -269,6 +278,97 @@ module GitlabInternalEventsCli
269
278
  cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
270
279
  end
271
280
 
281
+ def python_examples
282
+ identifier_params = identifiers.map do |identifier|
283
+ " #{identifier}_id: int,"
284
+ end
285
+
286
+ identifier_args = identifiers.map do |identifier|
287
+ " #{identifier}_id=#{identifier}_id,"
288
+ end
289
+
290
+ property_args = format_additional_properties do |property, value, description|
291
+ " #{property}=#{value}, # #{description}"
292
+ end
293
+
294
+ additional_properties_arg = if property_args.any?
295
+ " additional_properties=InternalEventAdditionalProperties(\n" \
296
+ "#{property_args.join("\n")}\n ),"
297
+ end
298
+
299
+ track_args = ["'#{action}',", *identifier_args, additional_properties_arg].compact.join("\n ")
300
+
301
+ function_params = [
302
+ *identifier_params,
303
+ ' internal_event_client: InternalEventsClient = Provide[',
304
+ ' ContainerApplication.internal_event.client',
305
+ ' ],'
306
+ ].join("\n")
307
+
308
+ imports = if property_args.any?
309
+ 'from lib.internal_events import InternalEventsClient, InternalEventAdditionalProperties'
310
+ else
311
+ 'from lib.internal_events import InternalEventsClient'
312
+ end
313
+
314
+ cli.say format_warning <<~TEXT
315
+ #{divider}
316
+ #{format_help('# PYTHON')}
317
+
318
+ #{imports}
319
+ from dependency_injector.wiring import Provide, inject
320
+ from ai_gateway.container import ContainerApplication
321
+
322
+ @inject
323
+ async def my_method(
324
+ #{function_params}
325
+ ):
326
+ internal_event_client.track_event(
327
+ #{track_args}
328
+ )
329
+
330
+ #{divider}
331
+ TEXT
332
+ end
333
+
334
+ def pytest_examples
335
+ property_args = format_additional_properties do |property, value, description|
336
+ " #{property}=#{value}, # #{description}"
337
+ end
338
+
339
+ additional_properties_arg = if property_args.any?
340
+ " additional_properties = InternalEventAdditionalProperties(\n" \
341
+ "#{property_args.join("\n")}\n )"
342
+ end
343
+
344
+ assert_args = ["event_name='#{action}',"]
345
+ assert_args << ' additional_properties=additional_properties,' if property_args.any?
346
+ identifiers.each { |id| assert_args << " #{id}_id=#{id}_id," }
347
+ assert_args_str = assert_args.join("\n ")
348
+
349
+ imports = ('from lib.internal_events import InternalEventAdditionalProperties' if property_args.any?)
350
+
351
+ cli.say format_warning <<~TEXT
352
+ #{divider}
353
+ #{format_help('# PYTEST')}
354
+
355
+ from unittest.mock import Mock
356
+ #{imports}
357
+
358
+ def test_#{action}(internal_event_client: Mock):
359
+ #{"#{additional_properties_arg}\n\n" if additional_properties_arg} instance = YourClass()
360
+ instance._internal_event_client = internal_event_client
361
+
362
+ instance.trigger_action()
363
+
364
+ internal_event_client.track_event.assert_called_once_with(
365
+ #{assert_args_str}
366
+ )
367
+
368
+ #{divider}
369
+ TEXT
370
+ end
371
+
272
372
  private
273
373
 
274
374
  def action
@@ -32,6 +32,42 @@ module GitlabInternalEventsCli
32
32
  events.slice(*event_paths)
33
33
  end
34
34
 
35
+ def generate_ai_event_suggestion(action)
36
+ ai_tracking_url = format_info('https://docs.gitlab.com/development/ai_features/usage_tracking/#adding-new-event-for-tracking')
37
+ ai_tracking_module = format_info('Gitlab::Tracking::AiTracking')
38
+
39
+ <<~TEXT
40
+ #{divider}
41
+ #{format_info('AI Tracking')}
42
+
43
+ Events with #{format_warning('classification: duo')} are required to be registered in #{ai_tracking_module}, you can do this in the following steps:
44
+
45
+ - #{format_info('Add the event name and the unique AI feature ID:')}
46
+
47
+ #{format_info('# Tracking without metadata')}
48
+ events(#{action}: AI_FEATURE_ID)
49
+
50
+ or, if your event has additional metadata:
51
+
52
+ #{format_info('# Tracking with metadata')}
53
+ events(#{event.action}: AI_FEATURE_ID) do |context|
54
+ { job_id: context['job'].id }
55
+ end
56
+
57
+ Learn more: #{ai_tracking_url}
58
+ TEXT
59
+ end
60
+
61
+ def generate_python_advice
62
+ <<~TEXT
63
+ \n
64
+ #{divider}
65
+ #{format_info('Python codebase')}
66
+
67
+ It seems like you are using the CLI in a python codebase. Make sure to check the python examples in the usage section.
68
+ TEXT
69
+ end
70
+
35
71
  private
36
72
 
37
73
  def trim_description(description)
@@ -31,13 +31,20 @@ module GitlabInternalEventsCli
31
31
  end
32
32
 
33
33
  def file_saved_message(verb, filepath)
34
- attributes = YAML.safe_load_file(absolute_path(filepath))
35
-
36
- format_prefix ' ', [
37
- file_saved_success_message(verb, filepath),
38
- file_saved_context_message(attributes),
39
- file_saved_validations_message(attributes)
40
- ].compact.join("\n")
34
+ # Context/validation messages only apply to YAML definition files.
35
+ # Other artifacts (e.g. generated Ruby class & spec files for database
36
+ # metrics) are reported with just the success line.
37
+ if filepath.end_with?('.yml', '.yaml')
38
+ attributes = YAML.safe_load_file(absolute_path(filepath))
39
+
40
+ format_prefix ' ', [
41
+ file_saved_success_message(verb, filepath),
42
+ file_saved_context_message(attributes),
43
+ file_saved_validations_message(attributes)
44
+ ].compact.join("\n")
45
+ else
46
+ format_prefix ' ', file_saved_success_message(verb, filepath)
47
+ end
41
48
  end
42
49
 
43
50
  def write_to_file(filepath, content, verb)
@@ -79,7 +79,15 @@ module GitlabInternalEventsCli
79
79
  end
80
80
  end
81
81
 
82
- NewMetric = Struct.new(*NEW_METRIC_FIELDS, :identifier, :actions, :key, :filters, :operator, keyword_init: true) do
82
+ # NOTE: :instrumentation_operation is kept on the struct but intentionally
83
+ # excluded from NEW_METRIC_FIELDS so it does NOT leak into the YAML output.
84
+ # It is only used to generate the Ruby instrumentation class file for
85
+ # database metrics.
86
+ NewMetric = Struct.new(
87
+ *NEW_METRIC_FIELDS,
88
+ :identifier, :actions, :key, :filters, :operator, :instrumentation_operation,
89
+ keyword_init: true
90
+ ) do
83
91
  def formatted_output
84
92
  extra_keys = event_metric? ? { events: events } : {}
85
93
 
@@ -107,6 +115,88 @@ module GitlabInternalEventsCli
107
115
  )
108
116
  end
109
117
 
118
+ # Path to the Ruby instrumentation class file generated for
119
+ # database metrics. Mirrors the layout used by the upstream
120
+ # `rails generate gitlab:usage_metric` generator.
121
+ def instrumentation_class_file_path
122
+ File.join(
123
+ *[
124
+ distribution_path,
125
+ 'lib', 'gitlab', 'usage', 'metrics', 'instrumentations',
126
+ "#{instrumentation_class_underscored}.rb"
127
+ ].compact
128
+ )
129
+ end
130
+
131
+ # Path to the RSpec file for the generated instrumentation class.
132
+ def instrumentation_class_spec_file_path
133
+ File.join(
134
+ *[
135
+ distribution_path,
136
+ 'spec', 'lib', 'gitlab', 'usage', 'metrics', 'instrumentations',
137
+ "#{instrumentation_class_underscored}_spec.rb"
138
+ ].compact
139
+ )
140
+ end
141
+
142
+ # Renders the body of the database instrumentation class.
143
+ #
144
+ # Based on upstream
145
+ # lib/generators/gitlab/usage_metric/templates/database_instrumentation_class.rb.template
146
+ # with one intentional deviation: the upstream Rails generator appends
147
+ # "Metric" to the class name from a separate argument, whereas the CLI
148
+ # asks the user to provide the full class name (e.g. "CountIssuesMetric")
149
+ # and interpolates it verbatim.
150
+ def instrumentation_class_content
151
+ <<~RUBY
152
+ # frozen_string_literal: true
153
+
154
+ module Gitlab
155
+ module Usage
156
+ module Metrics
157
+ module Instrumentations
158
+ class #{instrumentation_class} < DatabaseMetric
159
+ operation :#{instrumentation_operation}
160
+
161
+ relation do
162
+ # Insert ActiveRecord relation here
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ RUBY
170
+ end
171
+
172
+ # Renders the spec body for the generated instrumentation class.
173
+ #
174
+ # Differs from the upstream Rails template in two ways:
175
+ # 1. Uses the combined `a correct instrumented metric value and query`
176
+ # shared example (issue gitlab-org/gitlab#569191), where upstream uses
177
+ # `a correct instrumented metric value`.
178
+ # 2. Adds `data_source: 'database'` to the shared example params, matching
179
+ # the convention used by recent database metric specs in
180
+ # gitlab-org/gitlab (e.g. `count_admins_metric_spec.rb`).
181
+ def instrumentation_class_spec_content
182
+ <<~RUBY
183
+ # frozen_string_literal: true
184
+
185
+ require 'spec_helper'
186
+
187
+ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::#{instrumentation_class}, feature_category: :service_ping do
188
+ let(:expected_value) { 0 }
189
+ let(:expected_query) { '' }
190
+
191
+ it_behaves_like 'a correct instrumented metric value and query', { time_frame: 'all', data_source: 'database' }
192
+ end
193
+ RUBY
194
+ end
195
+
196
+ def database_metric?
197
+ data_source == 'database'
198
+ end
199
+
110
200
  def distribution_path
111
201
  'ee' unless tiers.include?('free')
112
202
  end
@@ -235,6 +325,37 @@ module GitlabInternalEventsCli
235
325
  def event_metric?
236
326
  data_source == 'internal_events'
237
327
  end
328
+
329
+ # Converts a CamelCase class name like "CountIssuesMetric" to
330
+ # the snake_case file name stem "count_issues_metric".
331
+ # Implemented without ActiveSupport to keep the gem dependency-free.
332
+ def instrumentation_class_underscored
333
+ return '' unless instrumentation_class
334
+
335
+ instrumentation_class
336
+ .gsub('::', '/')
337
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
338
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
339
+ .downcase
340
+ end
341
+
342
+ # Derives a snake_case metric-name suggestion from the
343
+ # instrumentation_class so database metrics can show a sensible
344
+ # default in the "How should we reference this metric?" prompt.
345
+ #
346
+ # Trailing `_metric` is stripped because the metric's key_path /
347
+ # filename should not end in `_metric` (the class name does, but
348
+ # the key path describes the value, not the Ruby class).
349
+ #
350
+ # Examples:
351
+ # "CountIssuesMetric" -> "count_issues"
352
+ # "CountStuffINTheDBMetric" -> "count_stuff_in_the_db"
353
+ # nil -> nil
354
+ def default_name_from_instrumentation_class
355
+ return unless instrumentation_class && !instrumentation_class.empty?
356
+
357
+ instrumentation_class_underscored.sub(/_metric\z/, '')
358
+ end
238
359
  end
239
360
 
240
361
  class Metric
@@ -8,6 +8,20 @@ module GitlabInternalEventsCli
8
8
 
9
9
  CLASS_NAME_REGEX = /\A[a-zA-Z]+\z/
10
10
 
11
+ # Maps each allowed database operation to its menu description.
12
+ # Mirrors the operations accepted by upstream
13
+ # `rails generate gitlab:usage_metric --type database`.
14
+ DATABASE_OPERATIONS = {
15
+ 'count' => 'count rows in the relation',
16
+ 'distinct_count' => 'count distinct values in a column',
17
+ 'estimate_batch_distinct_count' => 'approximate distinct count using HyperLogLog',
18
+ 'sum' => 'sum a numeric column',
19
+ 'average' => 'average a numeric column'
20
+ }.freeze
21
+
22
+ # Width used to align operation names with their descriptions in the menu.
23
+ OPERATION_NAME_WIDTH = DATABASE_OPERATIONS.keys.map(&:length).max
24
+
11
25
  attr_reader :metric
12
26
 
13
27
  def initialize(cli)
@@ -20,6 +34,7 @@ module GitlabInternalEventsCli
20
34
  metric.data_source = 'database'
21
35
 
22
36
  prompt_for_instrumentation_class
37
+ prompt_for_operation
23
38
  prompt_for_time_frame
24
39
  end
25
40
 
@@ -35,7 +50,10 @@ module GitlabInternalEventsCli
35
50
 
36
51
  TEXT
37
52
 
38
- metric.instrumentation_class = prompt_for_text(' Instrumentation class: ') do |q|
53
+ # multiline: true suppresses the duplicate `Input text:` prefix that
54
+ # TTY::Prompt would otherwise emit on the input line below — the
55
+ # question text already includes the prefix via the heredoc above.
56
+ metric.instrumentation_class = prompt_for_text(' Instrumentation class: ', multiline: true) do |q|
39
57
  q.required true
40
58
  q.messages[:required?] = INSTRUMENTATION_CLASS_HELP
41
59
  q.messages[:valid?] = INSTRUMENTATION_CLASS_ERROR
@@ -43,6 +61,21 @@ module GitlabInternalEventsCli
43
61
  end
44
62
  end
45
63
 
64
+ def prompt_for_operation
65
+ cli.say OPERATION_INTRO
66
+
67
+ metric.instrumentation_operation = cli.select(
68
+ 'Which operation should the metric perform?',
69
+ **select_opts
70
+ ) do |menu|
71
+ menu.enum '.'
72
+
73
+ DATABASE_OPERATIONS.each do |op, description|
74
+ menu.choice "#{op.ljust(OPERATION_NAME_WIDTH)} -- #{description}", op
75
+ end
76
+ end
77
+ end
78
+
46
79
  def prompt_for_time_frame
47
80
  metric.time_frame = cli.multi_select(
48
81
  'For which time frames do you want the metric to be calculated? (Space to select)',
@@ -44,6 +44,19 @@ module GitlabInternalEventsCli
44
44
 
45
45
  TEXT
46
46
 
47
+ DATABASE_METRIC_EXAMPLES = <<~TEXT
48
+ Database metrics derive their value from an ActiveRecord relation.
49
+
50
+ Common shapes:
51
+ count -- ex) total number of merge requests
52
+ distinct_count -- ex) count of distinct users who created a project
53
+ sum -- ex) total storage used across all projects
54
+ average -- ex) average issue weight
55
+
56
+ See https://docs.gitlab.com/development/internal_analytics/metrics/metrics_instrumentation/#database-metrics
57
+
58
+ TEXT
59
+
47
60
  EVENT_EXISTENCE_CHECK_INSTRUCTIONS = <<~TEXT.freeze
48
61
  To determine what to do next, let's figure out if the event is already tracked & usable.
49
62
 
@@ -5,13 +5,6 @@ module GitlabInternalEventsCli
5
5
  module MetricDefiner
6
6
  extend Helpers::Formatting
7
7
 
8
- DATABASE_METRIC_NOTICE = <<~TEXT
9
-
10
- For right now, this script can only define metrics for internal events.
11
-
12
- For more info on instrumenting database-backed metrics, see https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_instrumentation.html
13
- TEXT
14
-
15
8
  ALL_METRICS_EXIST_NOTICE = <<~TEXT
16
9
 
17
10
  Looks like the potential metrics for this event either already exist or are unsupported.
@@ -81,9 +74,16 @@ module GitlabInternalEventsCli
81
74
  TEXT
82
75
 
83
76
  DATABASE_METRIC_NAME_HELP = <<~TEXT.freeze
84
- #{format_warning('Required. Max %<count>s characters. Only lowercase/numbers/underscores allowed.')}
77
+ #{format_warning('Required. Max %<count>s characters. Only lowercase letters, numbers, and underscores are allowed.')}
78
+
79
+ This value becomes the metric's key path (ID) and YAML filename, so it must be snake_case.
80
+ It is intentionally different from the instrumentation class name you entered earlier:
85
81
 
86
- Choose a name considering that it should be clear and discoverable.
82
+ Instrumentation class (Ruby, CamelCase): %<class_name>s
83
+ Metric name (key/filename): %<suggested_name>s
84
+
85
+ Tip: a good metric name describes the value being measured, not the Ruby class.
86
+ Press Enter to accept the suggested default, or type your own snake_case name.
87
87
  TEXT
88
88
 
89
89
  NAME_REQUIREMENT_REASONS = {
@@ -106,12 +106,13 @@ module GitlabInternalEventsCli
106
106
  }.freeze
107
107
 
108
108
  NAME_ERROR = <<~TEXT.freeze
109
- #{format_warning('Input is invalid. Max %<count>s characters. Only lowercase/numbers/underscores allowed. Ensure this key path (ID) is not already in use.')}
109
+ #{format_warning('Input is invalid. Max %<count>s characters. Only lowercase letters, numbers, and underscores are allowed (snake_case). Ensure this key path (ID) is not already in use.')}
110
110
  TEXT
111
111
 
112
112
  INSTRUMENTATION_CLASS_INTRO = <<~TEXT.freeze
113
- #{format_info('METRIC INSTRUMENATION CLASS')}
113
+ #{format_info('METRIC INSTRUMENTATION CLASS')}
114
114
  Choose a name for the Ruby class that will be used to calculate the metric.
115
+ The CLI will scaffold the class file and a matching spec file for you.
115
116
 
116
117
  #{format_info('GOOD EXAMPLES:')}
117
118
  - CountSnippetsMetric
@@ -133,6 +134,20 @@ module GitlabInternalEventsCli
133
134
  INSTRUMENTATION_CLASS_ERROR = <<~TEXT.freeze
134
135
  #{format_warning('Input is invalid. Only lowercase/uppercase letters are allowed.')}
135
136
  TEXT
137
+
138
+ OPERATION_INTRO = <<~TEXT.freeze
139
+ #{format_info('METRIC OPERATION')}
140
+ Choose how the metric should derive its value from the database relation.
141
+
142
+ See the database metrics documentation for details:
143
+ https://docs.gitlab.com/development/internal_analytics/metrics/metrics_instrumentation/#database-metrics
144
+ TEXT
145
+
146
+ DATABASE_INSTRUMENTATION_FILES_NEXT_STEPS = <<~TEXT.freeze
147
+ - Fill in the ActiveRecord relation inside the generated instrumentation class.
148
+ - Adjust `expected_value` and `expected_query` in the generated spec to match your relation.
149
+ - Reference: #{format_info('https://docs.gitlab.com/development/internal_analytics/metrics/metrics_instrumentation/#database-metrics')}
150
+ TEXT
136
151
  end
137
152
  end
138
153
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitlabInternalEventsCli
4
- VERSION = '0.0.1'
4
+ VERSION = '0.1.1'
5
5
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabInternalEventsCli
4
+ class VersionChecker
5
+ RUBYGEMS_API_URL = 'https://rubygems.org/api/v1/gems/gitlab_internal_events_cli.json'
6
+ GEM_NAME = 'gitlab_internal_events_cli'
7
+
8
+ def initialize(timeout: 3)
9
+ @timeout = timeout
10
+ end
11
+
12
+ def latest_version
13
+ @latest_version ||= fetch_latest_version
14
+ end
15
+
16
+ def update_available?
17
+ return false unless latest_version
18
+
19
+ Gem::Version.new(latest_version) > Gem::Version.new(VERSION)
20
+ end
21
+
22
+ def self_update!
23
+ system('gem', 'install', GEM_NAME, '--no-document')
24
+ end
25
+
26
+ private
27
+
28
+ def fetch_latest_version
29
+ uri = URI(RUBYGEMS_API_URL)
30
+ response = Timeout.timeout(@timeout) { Net::HTTP.get(uri) }
31
+ gem_info = JSON.parse(response)
32
+
33
+ return unless gem_info.is_a?(Hash) && gem_info['version']
34
+
35
+ gem_info['version']
36
+ rescue StandardError
37
+ nil
38
+ end
39
+ end
40
+ end
@@ -20,6 +20,7 @@ require_relative 'gitlab_internal_events_cli/metric'
20
20
  require_relative 'gitlab_internal_events_cli/global_state'
21
21
  require_relative 'gitlab_internal_events_cli/helpers'
22
22
  require_relative 'gitlab_internal_events_cli/gitlab_prompt'
23
+ require_relative 'gitlab_internal_events_cli/version_checker'
23
24
  require_relative 'gitlab_internal_events_cli/cli'
24
25
  require_relative 'gitlab_internal_events_cli/text/event_definer'
25
26
  require_relative 'gitlab_internal_events_cli/text/metric_definer'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab_internal_events_cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-09 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json_schemer
@@ -141,6 +141,7 @@ files:
141
141
  - lib/gitlab_internal_events_cli/text/metric_definer.rb
142
142
  - lib/gitlab_internal_events_cli/time_framed_key_path.rb
143
143
  - lib/gitlab_internal_events_cli/version.rb
144
+ - lib/gitlab_internal_events_cli/version_checker.rb
144
145
  homepage: https://gitlab.com/gitlab-org/analytics-section/product-analytics/analytics-cli
145
146
  licenses:
146
147
  - MIT