logdash 1.0.1 → 1.1.0

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: 70a2fab7f429c39958bd7ead8fb4e65832e00210971ccc567ccf17cef7eb31dc
4
- data.tar.gz: cd6f25cd0edcdf3d1a2ff0bb4318e35dc3e1e8817d86626a6398aeb38788eaf6
3
+ metadata.gz: 0c20102d3b1fbe508e7520c30fcf14e87eb7cb07e315bc90972b45a6d4d6b873
4
+ data.tar.gz: 32dc2ed4ef2bfbbd122c4da789cb68bdde85ba32fbb10400fec6a92853bbcde1
5
5
  SHA512:
6
- metadata.gz: 39e5fa2e4d9a288b36c27f598941c4b4b74ab5b37c87db76ca034abdb8a097a9e8946db77ade930bcd97d26bf544fe900bab5ad54be9d95cd876accdd0cac2f8
7
- data.tar.gz: 7d4d669403861177cd8a2aac65be449185aebf73f463be83db7847a7b49b9c9b886d80ee17016bb6d874ef08e90a54759ac09ae12e64dc2005c804a8d0eed8f9
6
+ metadata.gz: 43c5dad64201cda45a6411302aeca58dab55c739d33fb525b56453b5c3a4b10ba2df707f18914df4ebb9586d23fd75531f4598d9506a3cc82365583f94063fc3
7
+ data.tar.gz: 6597038b2ea87972c0de8b95a3c4cd8e6df5a7fb1ca07ce759c1cb4b0a4340aa85320ebc7f77b0d6b0c67f2dbd07ce3ff6c8295b34b826351a492550c2404a2e
data/README.md CHANGED
@@ -49,12 +49,31 @@ logger.silly('Silly level logging')
49
49
  require 'logdash'
50
50
 
51
51
  logdash_client = Logdash.create(api_key: "YOUR_API_KEY")
52
- metrics = $logdash_client[:metrics]
52
+ metrics = logdash_client[:metrics]
53
53
 
54
54
  metrics.set('users', 0)
55
55
  metrics.mutate('users', 1)
56
56
  ```
57
57
 
58
+ ## Events
59
+
60
+ ```ruby
61
+ require 'logdash'
62
+
63
+ logdash_client = Logdash.create(api_key: "YOUR_API_KEY")
64
+ events = logdash_client[:events]
65
+
66
+ events.track('agent_run', { agent: 'support', steps: 4, success: true }, user_id: current_user.id)
67
+ ```
68
+
69
+ Events show up in your domain's Analytics as goals, with a daily trend.
70
+ Pass `user_id` for signed-in users, so one person counts once across your website and apps.
71
+ Without it, Logdash counts a visitor of that app that changes every day.
72
+ Never put personal data in props.
73
+
74
+ Events are sent in the background, and a script waits for them before it exits.
75
+ Call `events.flush` to wait for them sooner, for example before a serverless function returns.
76
+
58
77
  ## Ruby on Rails
59
78
 
60
79
  In a Ruby on Rails application, you can use an initializer to configure Logdash. Create a new file in `config/initializers/logdash.rb` with the following content:
@@ -66,18 +85,32 @@ ActiveSupport.on_load(:after_initialize) do
66
85
  $logdash_client = Logdash.create(api_key: "YOUR_API_KEY")
67
86
  $logger = $logdash_client[:logger]
68
87
  $metrics = $logdash_client[:metrics]
88
+ $events = $logdash_client[:events]
69
89
  end
70
90
  ```
71
91
 
72
- This will make `$logger` and `$metrics` available throughout your Ruby on Rails application.
92
+ This will make `$logger`, `$metrics` and `$events` available throughout your Ruby on Rails application.
73
93
 
74
94
  ## Configuration
75
95
 
76
- | Parameter | Required | Default | Description |
77
- | --------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
78
- | `api_key` | no | - | Api key used to authorize against logdash servers. If you don't provide one, logs will be logged into local console only |
79
- | `host` | no | `https://api.logdash.io` | Custom API host, useful with self-hosted instances |
80
- | `verbose` | no | `false` | Useful for debugging purposes |
96
+ | Parameter | Required | Default | Description |
97
+ | --------- | -------- | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
98
+ | `api_key` | no | - | API key used to authorize against Logdash servers. Without one, logs and events print to the local console only |
99
+ | `host` | no | `https://api.logdash.io` | Custom API host, useful with self-hosted instances |
100
+ | `verbose` | no | `false` | Useful for debugging purposes |
101
+ | `app` | no | - | Name of the app sending logs, metrics and events. See [Apps](#apps) |
102
+
103
+ ### Apps
104
+
105
+ A domain key is shared by all apps of a domain, for example a desktop app, an API and a landing page.
106
+ `app` names the app this client runs in, so the dashboard can filter logs, metrics and events by app:
107
+
108
+ ```ruby
109
+ logdash_client = Logdash.create(api_key: "YOUR_DOMAIN_KEY", app: "api")
110
+ ```
111
+
112
+ Logdash creates the app on first use.
113
+ Older service keys ignore `app`.
81
114
 
82
115
  ## View
83
116
 
@@ -3,41 +3,44 @@
3
3
  require_relative 'logger'
4
4
  require_relative 'metrics'
5
5
  require_relative 'http_log_sync'
6
+ require_relative 'events'
6
7
 
7
8
  module Logdash
8
9
  class Client
9
- attr_reader :logger, :metrics
10
+ attr_reader :logger, :metrics, :events
10
11
 
11
- def initialize(api_key: nil, host: 'https://api.logdash.io', verbose: false)
12
+ def initialize(api_key: nil, host: 'https://api.logdash.io', verbose: false, app: nil)
12
13
  @api_key = api_key
13
14
  @host = host
14
15
  @verbose = verbose
16
+ @app = app
15
17
 
16
- log_sync_instance = create_log_sync(api_key, host, verbose)
18
+ log_sync_instance = create_log_sync(api_key, host, verbose, app)
17
19
  @logger = Logger.new(log_sync: log_sync_instance)
18
20
 
19
- @metrics = create_metrics(api_key, host, verbose)
21
+ @metrics = create_metrics(api_key, host, verbose, app)
22
+ @events = Events.new(api_key: api_key, host: host, verbose: verbose, app: app)
20
23
  end
21
24
 
22
- def self.create(api_key: nil, host: 'https://api.logdash.io', verbose: false)
23
- client = new(api_key: api_key, host: host, verbose: verbose)
24
- { logger: client.logger, metrics: client.metrics }
25
+ def self.create(api_key: nil, host: 'https://api.logdash.io', verbose: false, app: nil)
26
+ client = new(api_key: api_key, host: host, verbose: verbose, app: app)
27
+ { logger: client.logger, metrics: client.metrics, events: client.events }
25
28
  end
26
29
 
27
30
  private
28
31
 
29
- def create_log_sync(api_key, host, verbose)
32
+ def create_log_sync(api_key, host, verbose, app)
30
33
  if api_key && !api_key.empty?
31
- HttpLogSync.new(api_key: api_key, host: host, verbose: verbose)
34
+ HttpLogSync.new(api_key: api_key, host: host, verbose: verbose, app: app)
32
35
  else
33
36
  puts '[LogDash] API key not provided. Using local logger only.' if verbose
34
37
  NoopLogSync.new
35
38
  end
36
39
  end
37
40
 
38
- def create_metrics(api_key, host, verbose)
41
+ def create_metrics(api_key, host, verbose, app)
39
42
  if api_key && !api_key.empty?
40
- Metrics.new(api_key: api_key, host: host, verbose: verbose)
43
+ Metrics.new(api_key: api_key, host: host, verbose: verbose, app: app)
41
44
  else
42
45
  puts '[LogDash] API key not provided. Metrics will not be registered.' if verbose
43
46
  NoopMetrics.new
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'securerandom'
7
+ require_relative 'logger'
8
+
9
+ module Logdash
10
+ class Events
11
+ NAME = /\A[a-z][a-z0-9_]{0,63}\z/.freeze
12
+ WEBSITE_EVENTS = %w[pageview pageleave browser_error].freeze
13
+
14
+ # Ruby kills the threads still sending events when the main thread exits, so it waits for them first
15
+ SENDING = ThreadGroup.new
16
+ at_exit { SENDING.list.each(&:join) }
17
+
18
+ def initialize(api_key:, host:, verbose:, app: nil)
19
+ @api_key = api_key
20
+ @host = host
21
+ @verbose = verbose
22
+ @app = app
23
+ @console = Logger.new
24
+ end
25
+
26
+ # Ruby < 3 passes a braced props hash as keywords, so keywords other than user_id are props too
27
+ def track(name, props = {}, user_id: nil, **more_props)
28
+ name = name.to_s
29
+ user_id = user_id.to_s
30
+ validate(name, user_id)
31
+ props = props.to_h.merge(more_props)
32
+ return @console.info("Event #{name} #{props.to_json}") if @api_key.to_s.empty?
33
+
34
+ body = event_body(name, props, user_id)
35
+ SENDING.add(Thread.new { send_event(name, body) })
36
+ rescue StandardError => e
37
+ @console.warn("Dropping event #{name.inspect}: #{e.message}")
38
+ end
39
+
40
+ # Waits for the events still being sent, for example before a serverless function returns
41
+ def flush
42
+ SENDING.list.each(&:join)
43
+ end
44
+
45
+ private
46
+
47
+ def validate(name, user_id)
48
+ if !NAME.match?(name) || WEBSITE_EVENTS.include?(name)
49
+ raise ArgumentError, 'its name must match [a-z][a-z0-9_]{0,63} and not be pageview, pageleave or browser_error'
50
+ end
51
+ raise ArgumentError, 'user_id must be an internal ID, never an email address' if user_id.include?('@')
52
+ end
53
+
54
+ def event_body(name, props, user_id)
55
+ # The server stores an event once per id, so a retry must send this same body
56
+ body = { id: SecureRandom.uuid, name: name }
57
+ body[:props] = props unless props.empty?
58
+ body[:userId] = user_id unless user_id.empty?
59
+ body[:app] = @app if @app && !@app.empty?
60
+ body.to_json
61
+ end
62
+
63
+ def send_event(name, body)
64
+ uri = URI.parse("#{@host}/events")
65
+ http = Net::HTTP.new(uri.host, uri.port)
66
+ http.use_ssl = (uri.scheme == 'https')
67
+ http.open_timeout = 5
68
+ http.read_timeout = 5
69
+ response = http.post(uri.request_uri, body, 'Content-Type' => 'application/json', 'project-api-key' => @api_key)
70
+ puts "[LogDash BG] Sent event (#{name}), status: #{response.code}" if @verbose
71
+ rescue StandardError => e
72
+ puts "[LogDash BG] Error sending event (#{name}): #{e.message}" if @verbose
73
+ end
74
+ end
75
+ end
@@ -6,10 +6,11 @@ require 'json'
6
6
 
7
7
  module Logdash
8
8
  class HttpLogSync
9
- def initialize(api_key:, host:, verbose:)
9
+ def initialize(api_key:, host:, verbose:, app: nil)
10
10
  @api_key = api_key
11
11
  @host = host
12
12
  @verbose = verbose
13
+ @app = app
13
14
  @sequence_number = 0
14
15
  @sequence_mutex = Mutex.new
15
16
  end
@@ -52,12 +53,9 @@ module Logdash
52
53
  request = Net::HTTP::Post.new(uri.request_uri)
53
54
  request['Content-Type'] = 'application/json'
54
55
  request['project-api-key'] = @api_key
55
- request.body = {
56
- message: message,
57
- level: level,
58
- createdAt: created_at,
59
- sequenceNumber: sequence_number
60
- }.to_json
56
+ body = { message: message, level: level, createdAt: created_at, sequenceNumber: sequence_number }
57
+ body[:app] = @app if @app && !@app.empty?
58
+ request.body = body.to_json
61
59
  request
62
60
  end
63
61
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'time'
4
5
 
5
6
  module Logdash
6
7
  module Types
@@ -59,19 +60,26 @@ module Logdash
59
60
  "\e[38;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m#{text}\e[0m"
60
61
  end
61
62
 
63
+ def iso8601_timestamp
64
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
65
+ end
66
+
62
67
  def default_prefix_proc
63
- lambda do |level|
64
- timestamp = colorize("[#{Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}]", Logdash::Types::LogLevel::SILLY)
68
+ lambda do |level, timestamp|
69
+ timestamp = colorize("[#{timestamp}]", Logdash::Types::LogLevel::SILLY)
65
70
  level_tag = colorize("[#{level.to_s.upcase}]", level)
66
71
  "#{timestamp} #{level_tag} "
67
72
  end
68
73
  end
69
74
 
70
75
  def _log(level, message)
71
- prefix = @prefix_proc.call(level)
76
+ timestamp = iso8601_timestamp
77
+ prefix = @prefix_proc.call(level, timestamp)
78
+
72
79
  puts "#{prefix}#{message}"
80
+
73
81
  @on_log_proc&.call(level, message)
74
- @log_sync&.send(message, level, Time.now.utc)
82
+ @log_sync&.send(message, level, timestamp)
75
83
  end
76
84
  end
77
85
  end
@@ -23,10 +23,11 @@ module Logdash
23
23
  class Metrics
24
24
  include BaseMetrics
25
25
 
26
- def initialize(api_key:, host:, verbose:)
26
+ def initialize(api_key:, host:, verbose:, app: nil)
27
27
  @api_key = api_key
28
28
  @host = host
29
29
  @verbose = verbose
30
+ @app = app
30
31
  end
31
32
 
32
33
  def set(name, value)
@@ -51,16 +52,7 @@ module Logdash
51
52
  http.open_timeout = 5
52
53
  http.read_timeout = 5
53
54
 
54
- request = Net::HTTP::Put.new(uri.request_uri)
55
- request['Content-Type'] = 'application/json'
56
- request['project-api-key'] = @api_key
57
- request.body = {
58
- name: name,
59
- value: value,
60
- operation: operation
61
- }.to_json
62
-
63
- response = http.request(request)
55
+ response = http.request(build_request(uri, name, value, operation))
64
56
  puts "[LogDash BG] Sent metric (#{name}, op: #{operation}), status: #{response.code}" if @verbose
65
57
  rescue Net::OpenTimeout, Net::ReadTimeout => e
66
58
  puts "[LogDash BG] Timeout sending metric (#{name}): #{e.message}" if @verbose
@@ -68,6 +60,16 @@ module Logdash
68
60
  puts "[LogDash BG] Error sending metric (#{name}): #{e.message}" if @verbose
69
61
  end
70
62
  end
63
+
64
+ def build_request(uri, name, value, operation)
65
+ request = Net::HTTP::Put.new(uri.request_uri)
66
+ request['Content-Type'] = 'application/json'
67
+ request['project-api-key'] = @api_key
68
+ body = { name: name, value: value, operation: operation }
69
+ body[:app] = @app if @app && !@app.empty?
70
+ request.body = body.to_json
71
+ request
72
+ end
71
73
  end
72
74
 
73
75
  class NoopMetrics
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Logdash
4
- VERSION = '1.0.1'
4
+ # CI writes the released version here before it builds the gem; the v<version> tags are the source of truth
5
+ VERSION = '1.1.0'
5
6
  end
data/lib/logdash.rb CHANGED
@@ -5,11 +5,11 @@ require_relative 'logdash/version'
5
5
  require_relative 'logdash/logger'
6
6
 
7
7
  module Logdash
8
- def self.create(api_key: nil, host: 'https://api.logdash.io', verbose: false)
9
- Client.create(api_key: api_key, host: host, verbose: verbose)
8
+ def self.create(api_key: nil, host: 'https://api.logdash.io', verbose: false, app: nil)
9
+ Client.create(api_key: api_key, host: host, verbose: verbose, app: app)
10
10
  end
11
11
 
12
- def self.new_client(api_key: nil, host: 'https://api.logdash.io', verbose: false)
13
- Client.new(api_key: api_key, host: host, verbose: verbose)
12
+ def self.new_client(api_key: nil, host: 'https://api.logdash.io', verbose: false, app: nil)
13
+ Client.new(api_key: api_key, host: host, verbose: verbose, app: app)
14
14
  end
15
15
  end
metadata CHANGED
@@ -1,43 +1,42 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logdash
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - krzysztoff1
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-05-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: json
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
- - - ">="
16
+ - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: '2.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
- - - ">="
23
+ - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
25
+ version: '2.0'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: bundler
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - ">="
30
+ - - "~>"
32
31
  - !ruby/object:Gem::Version
33
- version: '0'
32
+ version: '2.0'
34
33
  type: :development
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
- - - ">="
37
+ - - "~>"
39
38
  - !ruby/object:Gem::Version
40
- version: '0'
39
+ version: '2.0'
41
40
  - !ruby/object:Gem::Dependency
42
41
  name: rake
43
42
  requirement: !ruby/object:Gem::Requirement
@@ -86,42 +85,56 @@ dependencies:
86
85
  requirements:
87
86
  - - "~>"
88
87
  - !ruby/object:Gem::Version
89
- version: '2.25'
88
+ version: '3.0'
90
89
  type: :development
91
90
  prerelease: false
92
91
  version_requirements: !ruby/object:Gem::Requirement
93
92
  requirements:
94
93
  - - "~>"
95
94
  - !ruby/object:Gem::Version
96
- version: '2.25'
95
+ version: '3.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: simplecov
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.22'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.22'
97
110
  - !ruby/object:Gem::Dependency
98
111
  name: timecop
99
112
  requirement: !ruby/object:Gem::Requirement
100
113
  requirements:
101
- - - ">="
114
+ - - "~>"
102
115
  - !ruby/object:Gem::Version
103
- version: '0'
116
+ version: '0.9'
104
117
  type: :development
105
118
  prerelease: false
106
119
  version_requirements: !ruby/object:Gem::Requirement
107
120
  requirements:
108
- - - ">="
121
+ - - "~>"
109
122
  - !ruby/object:Gem::Version
110
- version: '0'
123
+ version: '0.9'
111
124
  - !ruby/object:Gem::Dependency
112
125
  name: webmock
113
126
  requirement: !ruby/object:Gem::Requirement
114
127
  requirements:
115
- - - ">="
128
+ - - "~>"
116
129
  - !ruby/object:Gem::Version
117
- version: '0'
130
+ version: '3.18'
118
131
  type: :development
119
132
  prerelease: false
120
133
  version_requirements: !ruby/object:Gem::Requirement
121
134
  requirements:
122
- - - ">="
135
+ - - "~>"
123
136
  - !ruby/object:Gem::Version
124
- version: '0'
137
+ version: '3.18'
125
138
  description: A Ruby client to send logs and metrics to the LogDash.
126
139
  email:
127
140
  - hello@krzysztof.studio
@@ -129,12 +142,11 @@ executables: []
129
142
  extensions: []
130
143
  extra_rdoc_files: []
131
144
  files:
132
- - ".rubocop.yml"
133
- - CHANGELOG.md
134
145
  - LICENSE
135
146
  - README.md
136
147
  - lib/logdash.rb
137
148
  - lib/logdash/client.rb
149
+ - lib/logdash/events.rb
138
150
  - lib/logdash/http_log_sync.rb
139
151
  - lib/logdash/logger.rb
140
152
  - lib/logdash/metrics.rb
@@ -145,9 +157,8 @@ licenses:
145
157
  metadata:
146
158
  homepage_uri: https://logdash.io/
147
159
  source_code_uri: https://github.com/logdash-io/ruby-sdk
148
- changelog_uri: https://github.com/logdash-io/ruby-sdk/blob/main/CHANGELOG.md
160
+ changelog_uri: https://github.com/logdash-io/ruby-sdk/releases
149
161
  rubygems_mfa_required: 'true'
150
- post_install_message:
151
162
  rdoc_options: []
152
163
  require_paths:
153
164
  - lib
@@ -162,8 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
162
173
  - !ruby/object:Gem::Version
163
174
  version: '0'
164
175
  requirements: []
165
- rubygems_version: 3.5.20
166
- signing_key:
176
+ rubygems_version: 3.6.9
167
177
  specification_version: 4
168
178
  summary: Ruby client for LogDash observability platform.
169
179
  test_files: []
data/.rubocop.yml DELETED
@@ -1,58 +0,0 @@
1
- require:
2
- - rubocop-rspec
3
-
4
- AllCops:
5
- NewCops: enable
6
- TargetRubyVersion: 2.6
7
- Exclude:
8
- - "bin/*"
9
- - "vendor/**/*"
10
- - "tmp/**/*"
11
- - "logdash-*.gem"
12
- SuggestExtensions: false
13
-
14
- Capybara:
15
- Enabled: false
16
-
17
- FactoryBot:
18
- Enabled: false
19
-
20
- Capybara/RSpec/PredicateMatcher:
21
- Enabled: false
22
-
23
- Gemspec/DevelopmentDependencies:
24
- Enabled: false
25
-
26
- Style/Documentation:
27
- Enabled: false
28
-
29
- Style/FrozenStringLiteralComment:
30
- Enabled: true
31
-
32
- Layout/LineLength:
33
- Max: 120
34
-
35
- Metrics/BlockLength:
36
- Exclude:
37
- - "spec/**/*"
38
- - "test/**/*"
39
- - "ruby-sdk.gemspec"
40
-
41
- Metrics/ModuleLength:
42
- Max: 150
43
-
44
- Metrics/ClassLength:
45
- Max: 150
46
-
47
- Metrics/CyclomaticComplexity:
48
- Max: 10
49
-
50
- Metrics/PerceivedComplexity:
51
- Max: 10
52
-
53
- Metrics/AbcSize:
54
- Max: 26
55
-
56
- Metrics/MethodLength:
57
- Exclude:
58
- - "lib/logdash/metrics.rb"
data/CHANGELOG.md DELETED
@@ -1,13 +0,0 @@
1
- # Changelog
2
-
3
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
-
5
- ## [1.0.1] - 2025-05-22
6
-
7
- ### Added
8
- - Ensure iso8601 timestamp format is available in other environments
9
-
10
- ## [1.0.0] - 2025-05-21
11
-
12
- ### Added
13
- - Initial release