umami-ruby 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +43 -6
- data/context7.json +4 -0
- data/lib/umami/client.rb +87 -0
- data/lib/umami/errors.rb +10 -3
- data/lib/umami/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: adc8943170e57c2126ffddff58f399b17ff3cb6f0736b293a75fd32c1f1fe6c7
|
|
4
|
+
data.tar.gz: 8a5477ecfeaca6b2cb2857c3b715fa4d32b01ad953b2dd640385d7344c526e0f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae319a33a3471d8688dfc36444eca87130c1c49fac89694f99377e91b5823454592164b18667cb06bc58fe43e2373040fa061772e79057ba02f76d9132bcd9e3
|
|
7
|
+
data.tar.gz: 87991d5621f16af9db3ae88e29a1b9b2d003c579418e1b92fca9054800aaea7f1ddacc7cc4c4922388dff0de8a0df9f0b8e6f429fbe81704604bd93351b3f5fc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [0.4.0] - 2026-11-06
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- `website_pageviews_over_range`: pageviews and sessions over a range of any length, keeping the unit you asked for. Umami silently answers `unit: "day"` in months once a range spans more than 7 calendar months (and `unit: "hour"` in days past 30 days), so a year of daily points came back as twelve monthly ones. The new method splits the range into requests short enough for the unit (90 days for `day`, 30 for `hour`) and joins the results.
|
|
6
|
+
|
|
7
|
+
### Documentation
|
|
8
|
+
|
|
9
|
+
- README: "Two traps in the Umami API". List endpoints return 20 items unless you pass `pageSize`, and long ranges come back in coarser units than requested. The basic-usage example now passes `pageSize`.
|
|
10
|
+
|
|
11
|
+
## [0.3.0] - 2026-07-24
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Username/password authentication against self-hosted instances actually works now.** Since the connection memoization was introduced, `authenticate` built (and memoized) the Faraday connection in order to POST `/api/auth/login` — before `@access_token` existed. Faraday bakes headers in at connection build time, so every request after a successful login went out without an `Authorization` header and failed with 401. The token-based flow was unaffected, which is how this survived: docs and tests exercised logins, never "login **then** call an endpoint". `authenticate` now drops the memoized connection after storing the token so the next request rebuilds it with the Bearer header, and the new `test/integration/authentication_flow_test.rb` pins the full lifecycle (login → authorized request → connection reuse without re-login).
|
|
16
|
+
- `authenticate` raises `Umami::AuthenticationError` when the login response is a 200 without a `token` field, instead of leaving a client that 401s on every call with no hint why.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- HTTP 401 and 403 responses now raise `Umami::AuthenticationError` instead of the undifferentiated `Umami::ClientError`, giving long-lived clients a re-authentication signal (expired/revoked token, insufficient permissions). To keep existing `rescue Umami::APIError` handlers working — they used to catch these as `ClientError` — `AuthenticationError` is now a subclass of `Umami::APIError` (previously it subclassed `Umami::Error` directly). Code matching `Umami::ClientError` specifically for 401/403 must switch to `Umami::AuthenticationError`.
|
|
21
|
+
|
|
1
22
|
## [0.2.0] - 2026-02-02
|
|
2
23
|
|
|
3
24
|
### Added
|
data/README.md
CHANGED
|
@@ -95,8 +95,8 @@ client = Umami::Client.new
|
|
|
95
95
|
# Get current user info
|
|
96
96
|
me = client.me
|
|
97
97
|
|
|
98
|
-
# Get
|
|
99
|
-
websites = client.websites
|
|
98
|
+
# Get your websites (first page only: Umami pages lists at 20, see below)
|
|
99
|
+
websites = client.websites(pageSize: 200)
|
|
100
100
|
|
|
101
101
|
# Get a specific website
|
|
102
102
|
website = client.website("website_id")
|
|
@@ -125,6 +125,14 @@ pageviews = client.website_pageviews("website_id", {
|
|
|
125
125
|
timezone: "America/Los_Angeles"
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
# Pageviews over a range of any length, keeping daily points (see below)
|
|
129
|
+
year = client.website_pageviews_over_range("website_id", {
|
|
130
|
+
startAt: Time.utc(2025, 11, 1).to_i * 1000,
|
|
131
|
+
endAt: Time.utc(2026, 11, 1).to_i * 1000 - 1,
|
|
132
|
+
unit: "day",
|
|
133
|
+
timezone: "UTC"
|
|
134
|
+
})
|
|
135
|
+
|
|
128
136
|
# Get metrics by type
|
|
129
137
|
metrics = client.website_metrics("website_id", {
|
|
130
138
|
startAt: 1656679719687,
|
|
@@ -325,6 +333,34 @@ client.reset_website("website_id")
|
|
|
325
333
|
client.delete_website("website_id")
|
|
326
334
|
```
|
|
327
335
|
|
|
336
|
+
### Two traps in the Umami API
|
|
337
|
+
|
|
338
|
+
Both fail silently: you get a normal-looking answer with data missing.
|
|
339
|
+
|
|
340
|
+
**Lists stop at 20 items.** Every list endpoint (`websites`, `my_websites`,
|
|
341
|
+
`team_websites`, `admin_websites`, users, teams, reports, ...) returns one page
|
|
342
|
+
of 20 unless you ask for more. With 32 sites, `client.websites` returns 20, and
|
|
343
|
+
the other 12 look like they don't exist. Pass `pageSize`, and compare the
|
|
344
|
+
response's `count` with the items you got:
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
response = client.websites(pageSize: 200)
|
|
348
|
+
response["data"].size == response["count"] # otherwise, fetch the next page
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**Long ranges come back in coarser units than you asked for.** If a range is
|
|
352
|
+
too long for the requested `unit`, the server switches to a coarser one and
|
|
353
|
+
says nothing. On Umami 3.x, `unit: "day"` turns into monthly points once the
|
|
354
|
+
range spans more than 7 calendar months, and `unit: "hour"` turns into daily
|
|
355
|
+
points past 30 days (`getMinimumUnit` in Umami's `src/lib/date.ts`). A year of
|
|
356
|
+
`unit: "day"` from `website_pageviews` is twelve monthly points, and code that
|
|
357
|
+
reads them as days puts a month's total on one day.
|
|
358
|
+
|
|
359
|
+
`website_pageviews_over_range` takes the same params as `website_pageviews`,
|
|
360
|
+
splits a long range into requests short enough for the unit (90 days for
|
|
361
|
+
`day`, 30 for `hour`), and joins the results. Start `startAt` at midnight in
|
|
362
|
+
the `timezone` you pass, so no day is split across two requests.
|
|
363
|
+
|
|
328
364
|
## Available Methods
|
|
329
365
|
|
|
330
366
|
### Authentication
|
|
@@ -374,6 +410,7 @@ client.delete_website("website_id")
|
|
|
374
410
|
- `website_stats(website_id, params)` - Get statistics
|
|
375
411
|
- `website_active_visitors(website_id)` - Get active visitors
|
|
376
412
|
- `website_pageviews(website_id, params)` - Get pageviews
|
|
413
|
+
- `website_pageviews_over_range(website_id, params)` - Get pageviews over a range of any length, keeping the requested unit
|
|
377
414
|
- `website_events(website_id, params)` - Get events (time-series)
|
|
378
415
|
- `website_events_series(website_id, params)` - Get events series
|
|
379
416
|
- `website_metrics(website_id, params)` - Get metrics
|
|
@@ -440,12 +477,12 @@ begin
|
|
|
440
477
|
client.website("non-existent-id")
|
|
441
478
|
rescue Umami::NotFoundError => e
|
|
442
479
|
puts "Website not found: #{e.message}"
|
|
480
|
+
rescue Umami::AuthenticationError => e
|
|
481
|
+
puts "Authentication failed: #{e.message}" # rejected login, expired/revoked token (401), forbidden (403)
|
|
443
482
|
rescue Umami::ClientError => e
|
|
444
483
|
puts "Client error: #{e.message}"
|
|
445
484
|
rescue Umami::ServerError => e
|
|
446
485
|
puts "Server error: #{e.message}"
|
|
447
|
-
rescue Umami::AuthenticationError => e
|
|
448
|
-
puts "Authentication failed: #{e.message}"
|
|
449
486
|
rescue Umami::ConfigurationError => e
|
|
450
487
|
puts "Configuration error: #{e.message}"
|
|
451
488
|
rescue Umami::APIError => e
|
|
@@ -456,10 +493,10 @@ end
|
|
|
456
493
|
**Error class hierarchy:**
|
|
457
494
|
- `Umami::Error` - Base error class
|
|
458
495
|
- `Umami::ConfigurationError` - Configuration issues
|
|
459
|
-
- `Umami::AuthenticationError` - Authentication failures
|
|
460
496
|
- `Umami::APIError` - API request failures
|
|
497
|
+
- `Umami::AuthenticationError` - Authentication failures: rejected logins, and HTTP 401/403 API responses (since 0.3.0 — an expired token on a long-lived client surfaces here so you can re-authenticate)
|
|
461
498
|
- `Umami::NotFoundError` - Resource not found (404)
|
|
462
|
-
- `Umami::ClientError` -
|
|
499
|
+
- `Umami::ClientError` - Other client errors (4xx)
|
|
463
500
|
- `Umami::ServerError` - Server errors (5xx)
|
|
464
501
|
|
|
465
502
|
## Logging
|
data/context7.json
ADDED
data/lib/umami/client.rb
CHANGED
|
@@ -72,6 +72,25 @@ module Umami
|
|
|
72
72
|
|
|
73
73
|
data = JSON.parse(response.body)
|
|
74
74
|
@access_token = data["token"]
|
|
75
|
+
|
|
76
|
+
# Umami answers a successful login with { "token": ..., "user": ... }
|
|
77
|
+
# (https://umami.is/docs/api/authentication#post-/api/auth/login).
|
|
78
|
+
# A 200 without a token would otherwise produce a client that fails
|
|
79
|
+
# every request with an opaque 401 — fail loudly at the source.
|
|
80
|
+
if @access_token.nil? || @access_token.empty?
|
|
81
|
+
raise Umami::AuthenticationError, "Authentication succeeded but the response carried no token"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ⚠️ Load-bearing reset. The login POST above just memoized
|
|
85
|
+
# `@connection` — necessarily WITHOUT an Authorization header, since
|
|
86
|
+
# `@access_token` was still nil when the connection was built, and
|
|
87
|
+
# Faraday bakes headers into the connection at build time. Dropping
|
|
88
|
+
# the memo forces the next request to rebuild the connection, this
|
|
89
|
+
# time with the Bearer token (see #connection). Without this reset,
|
|
90
|
+
# every request after a username/password login fails with 401 —
|
|
91
|
+
# the exact bug shipped in <= 0.2.0 (why token-based auth was the
|
|
92
|
+
# only flow that worked against self-hosted instances).
|
|
93
|
+
@connection = nil
|
|
75
94
|
rescue Faraday::Error, JSON::ParserError => e
|
|
76
95
|
raise Umami::AuthenticationError, "Authentication failed: #{e.message}"
|
|
77
96
|
end
|
|
@@ -494,6 +513,59 @@ module Umami
|
|
|
494
513
|
get("/api/websites/#{website_id}/pageviews", params)
|
|
495
514
|
end
|
|
496
515
|
|
|
516
|
+
# Longest range, in days, that one pageviews request can cover and still
|
|
517
|
+
# get the unit it asked for. Umami silently picks a coarser unit when the
|
|
518
|
+
# range is too long for the one requested: 3.x answers `unit: "day"` in
|
|
519
|
+
# months once the range spans more than 7 calendar months, and
|
|
520
|
+
# `unit: "hour"` in days past 30 days (src/lib/date.ts, getMinimumUnit).
|
|
521
|
+
# These limits leave room for older servers too.
|
|
522
|
+
PAGEVIEWS_DAYS_PER_REQUEST = { "hour" => 30, "day" => 90 }.freeze
|
|
523
|
+
|
|
524
|
+
# Get pageviews and sessions over a range of any length, keeping the unit
|
|
525
|
+
# you asked for
|
|
526
|
+
#
|
|
527
|
+
# Takes the same params as {#website_pageviews}. A range longer than one
|
|
528
|
+
# request can hold for that unit (see PAGEVIEWS_DAYS_PER_REQUEST) is split
|
|
529
|
+
# into consecutive requests, and their series are joined. Without this,
|
|
530
|
+
# a year of `unit: "day"` comes back as twelve monthly points, with no
|
|
531
|
+
# error.
|
|
532
|
+
#
|
|
533
|
+
# Start `startAt` at midnight (or on the hour) in the `timezone` you pass,
|
|
534
|
+
# so no bucket is split across two requests. If one is, its counts are
|
|
535
|
+
# added together, which can count a visitor twice in "sessions".
|
|
536
|
+
#
|
|
537
|
+
# @param website_id [String] The website's ID
|
|
538
|
+
# @param params [Hash] Same as {#website_pageviews}; :startAt, :endAt and :unit are required
|
|
539
|
+
# @return [Hash] "pageviews" and "sessions" arrays of { "x" => bucket, "y" => count }, in order
|
|
540
|
+
# @example A year of daily visitors
|
|
541
|
+
# client.website_pageviews_over_range(website_id,
|
|
542
|
+
# startAt: Time.utc(2025, 11, 1).to_i * 1000,
|
|
543
|
+
# endAt: Time.utc(2026, 11, 1).to_i * 1000 - 1,
|
|
544
|
+
# unit: "day", timezone: "UTC")
|
|
545
|
+
def website_pageviews_over_range(website_id, params = {})
|
|
546
|
+
params = params.transform_keys(&:to_sym)
|
|
547
|
+
days = PAGEVIEWS_DAYS_PER_REQUEST[params[:unit].to_s]
|
|
548
|
+
return website_pageviews(website_id, params) unless days
|
|
549
|
+
|
|
550
|
+
start_at, end_at = Integer(params.fetch(:startAt)), Integer(params.fetch(:endAt))
|
|
551
|
+
step = days * 24 * 60 * 60 * 1000
|
|
552
|
+
series = Hash.new { |hash, key| hash[key] = {} }
|
|
553
|
+
|
|
554
|
+
start_at.step(end_at, step) do |from|
|
|
555
|
+
response = website_pageviews(website_id, params.merge(startAt: from, endAt: [from + step - 1, end_at].min))
|
|
556
|
+
|
|
557
|
+
%w[pageviews sessions].each do |key|
|
|
558
|
+
Array(response[key]).each do |point|
|
|
559
|
+
series[key][point["x"]] = series[key].fetch(point["x"], 0) + point["y"].to_i
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
%w[pageviews sessions].to_h do |key|
|
|
565
|
+
[key, series[key].map { |x, y| { "x" => x, "y" => y } }]
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
497
569
|
# Get website events within a time range
|
|
498
570
|
#
|
|
499
571
|
# This method returns event data with optional time-series grouping.
|
|
@@ -1298,6 +1370,12 @@ module Umami
|
|
|
1298
1370
|
handle_error(e)
|
|
1299
1371
|
end
|
|
1300
1372
|
|
|
1373
|
+
# Memoized so keep-alive and middleware setup are paid once, BUT the
|
|
1374
|
+
# Authorization header is baked in at build time — Faraday connections
|
|
1375
|
+
# don't re-read `@access_token` afterwards. Anything that changes the
|
|
1376
|
+
# token MUST set `@connection = nil` so the next request rebuilds with
|
|
1377
|
+
# the fresh header (see #authenticate, where forgetting exactly that
|
|
1378
|
+
# was the <= 0.2.0 username/password bug).
|
|
1301
1379
|
def connection
|
|
1302
1380
|
@connection ||= Faraday.new(url: uri_base) do |faraday|
|
|
1303
1381
|
faraday.request :json
|
|
@@ -1308,8 +1386,17 @@ module Umami
|
|
|
1308
1386
|
end
|
|
1309
1387
|
end
|
|
1310
1388
|
|
|
1389
|
+
# Order matters: Faraday::UnauthorizedError (401) and ForbiddenError
|
|
1390
|
+
# (403) are SUBCLASSES of Faraday::ClientError
|
|
1391
|
+
# (https://lostisland.github.io/faraday/#/middleware/included/raising-errors),
|
|
1392
|
+
# so they must be matched before the generic 4xx case or they'd
|
|
1393
|
+
# surface as an undifferentiated ClientError. Raising
|
|
1394
|
+
# AuthenticationError instead gives callers a re-authentication signal
|
|
1395
|
+
# (e.g. an expired or revoked token on a long-lived client).
|
|
1311
1396
|
def handle_error(error)
|
|
1312
1397
|
case error
|
|
1398
|
+
when Faraday::UnauthorizedError, Faraday::ForbiddenError
|
|
1399
|
+
raise Umami::AuthenticationError, "Authentication failed: #{error.message}"
|
|
1313
1400
|
when Faraday::ResourceNotFound
|
|
1314
1401
|
raise Umami::NotFoundError, "Resource not found: #{error.message}"
|
|
1315
1402
|
when Faraday::ClientError
|
data/lib/umami/errors.rb
CHANGED
|
@@ -5,12 +5,19 @@ module Umami
|
|
|
5
5
|
# Error raised when there's a configuration issue
|
|
6
6
|
class ConfigurationError < Error; end
|
|
7
7
|
|
|
8
|
-
# Error raised when authentication fails
|
|
9
|
-
class AuthenticationError < Error; end
|
|
10
|
-
|
|
11
8
|
# Base error class for API-related errors
|
|
12
9
|
class APIError < Error; end
|
|
13
10
|
|
|
11
|
+
# Error raised when authentication fails — a rejected login, a login
|
|
12
|
+
# response without a token, or an API request answered 401/403 (an
|
|
13
|
+
# expired or revoked token on a long-lived client, insufficient
|
|
14
|
+
# permissions). Deliberately a subclass of APIError since 0.2.1:
|
|
15
|
+
# before that, HTTP 401/403 surfaced as ClientError, so callers who
|
|
16
|
+
# `rescue Umami::APIError` were already catching them — subclassing
|
|
17
|
+
# APIError keeps those rescues working while giving auth failures
|
|
18
|
+
# their own class to match on.
|
|
19
|
+
class AuthenticationError < APIError; end
|
|
20
|
+
|
|
14
21
|
# Error raised when a resource is not found
|
|
15
22
|
class NotFoundError < APIError; end
|
|
16
23
|
|
data/lib/umami/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: umami-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-11-06 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: faraday
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- LICENSE.txt
|
|
40
40
|
- README.md
|
|
41
41
|
- Rakefile
|
|
42
|
+
- context7.json
|
|
42
43
|
- docs/Umami.html
|
|
43
44
|
- docs/Umami/APIError.html
|
|
44
45
|
- docs/Umami/AuthenticationError.html
|