@ossy/analytics 3.11.1 → 3.12.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.
- package/README.md +14 -11
- package/SPEC.md +177 -0
- package/package.json +11 -6
- package/src/AnalyticsProductHome.jsx +139 -33
- package/src/Definition.js +1 -1
- package/src/analytics-home-content.js +4 -4
- package/src/analytics.page.jsx +1 -1
- package/src/create-page-view.action.js +1 -0
- package/src/create-page-view.task.js +55 -0
- package/src/create-page-view.task.spec.js +170 -0
- package/src/en.translations.json +25 -14
- package/src/get-page-view-stats.action.js +1 -0
- package/src/get-page-view-stats.task.js +129 -0
- package/src/get-page-view-stats.task.spec.js +146 -0
- package/src/host-from-domain-resource.js +9 -0
- package/src/host-from-domain-resource.spec.js +15 -0
- package/src/index.js +2 -0
- package/src/page-view-location.aggregate.js +94 -0
- package/src/page-view-location.aggregate.spec.js +103 -0
- package/src/resolve-country-from-ip.js +85 -0
- package/src/resolve-country-from-ip.spec.js +52 -0
- package/src/resolve-page-view-workspace.js +110 -0
- package/src/resolve-page-view-workspace.spec.js +101 -0
- package/src/sv.translations.json +25 -14
- package/src/usePageViewStats.js +11 -6
- package/src/view-analytics.flow.js +4 -4
- package/src/useWorkspaceKpis.js +0 -45
package/README.md
CHANGED
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
# @ossy/analytics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Host-traffic analytics. See **[SPEC.md](./SPEC.md)**.
|
|
4
4
|
|
|
5
|
-
Platform-wide (ops) insights belong in a
|
|
5
|
+
Platform-wide (ops) insights belong in a back-office Mongo dashboard — [ADR 0014](../../docs/adr/0014-system-insights-in-back-office.md).
|
|
6
6
|
|
|
7
7
|
## Pages
|
|
8
8
|
|
|
9
9
|
| Page | Path | Audience |
|
|
10
10
|
|------|------|----------|
|
|
11
|
-
| `analytics/home` | `/analytics` · `/analys` | Signed-in + entitled →
|
|
11
|
+
| `analytics/home` | `/analytics` · `/analys` | Signed-in + entitled → traffic for a **registered Host**. No domain → empty state. Otherwise the public sales page (no stats loads). |
|
|
12
|
+
|
|
13
|
+
Workspace member/resource counts are **not** this package — they belong on workspace settings.
|
|
12
14
|
|
|
13
15
|
## Actions
|
|
14
16
|
|
|
15
17
|
| Action | Access | Notes |
|
|
16
18
|
|--------|--------|-------|
|
|
17
|
-
| `@ossy/analytics/actions/
|
|
18
|
-
| `@ossy/
|
|
19
|
+
| `@ossy/analytics/actions/create-page-view` | `public` | Ingest. Server resolves Host (`hostname` / `Origin`) via managed-domain registry. Unknown Host → 200 no-op (`recorded: false`). Connection `ip` is stamped on the eventstore row (ADR 0016). |
|
|
20
|
+
| `@ossy/analytics/actions/get-page-view-stats` | `workspace` | Aggregates for a Host this workspace registered (`host` required). Window is resource `created`. Returns `uniqueVisitors`, paths, and `byCountry`. |
|
|
21
|
+
| `@ossy/analytics/actions/get-workspace-kpis` | `workspace` | Member + resource counts — UI on `/settings`; not shown on `/analytics`. |
|
|
22
|
+
|
|
23
|
+
## Locations (#769)
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
**Event (source of truth):** after Host allowlist, the page-view **eventstore row** carries the trusted connection IP (`ip`, ADR 0016). Client body `ip` / `countryCode` and viewer geo headers are ignored. Unknown Hosts never write an event.
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
import { GetWorkspaceKpis } from '@ossy/analytics'
|
|
27
|
+
**Read model:** `PageViewLocationProjection` (`@ossy/analytics/data/page-view-location`) maps `event.ip` → ISO `countryCode` via MaxMind GeoLite2-Country (`GEOLITE2_COUNTRY_MMDB`) and folds counts into daily country buckets (`byDay[YYYY-MM-DD][CC]`) scoped per Host (`workspaceId:host`). Day keys use envelope `created`. Rebuilding with a new `.mmdb` refreshes historical countries while events keep raw IPs.
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
```
|
|
29
|
+
**UI:** location / `byCountry` only — never show IP. Host-scoped (pick a domain on `/analytics`).
|
|
27
30
|
|
|
28
31
|
## Preview
|
|
29
32
|
|
|
30
|
-
Enable Analytics
|
|
33
|
+
Enable Analytics, register a domain on the workspace, then open `/analytics` and select that Host.
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Analytics — Feature Specification
|
|
2
|
+
|
|
3
|
+
> Package: `@ossy/analytics` · Status: **beta** (Host-traffic model)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Operators need to know **how many people visited a Host** (e.g. `ossy.se`) — unique visitors and page views. That includes anonymous traffic and signed-in members. Attribution must follow the **Host**, not the visitor’s selected workspace.
|
|
10
|
+
|
|
11
|
+
**Core user story:** "I register a domain on my workspace. Visitors to that Host are counted (signed in or not). I open `/analytics`, pick that domain, and see unique visitors and page views."
|
|
12
|
+
|
|
13
|
+
**Non-goal:** Attributing views to the visitor’s selected workspace (cookie / `appWorkspaceId` / client `workspaceId`). A signed-in member of another workspace visiting `ossy.se` counts on Ossy’s Host.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Tenancy
|
|
18
|
+
|
|
19
|
+
| Layer | Meaning |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Host | A registered managed domain (`@ossy/domains/schema/managed-domain`; hostname on `state.name` and/or `content.Domain`) |
|
|
22
|
+
| Workspace | Owns zero or more Hosts (`belongsTo` on the domain resource) |
|
|
23
|
+
| Write | One public ingest. Attribution is **Host → workspace**. Never client `workspaceId`. |
|
|
24
|
+
| Read | Workspace members see stats **for a Host they registered**, not a blob of all workspace page views |
|
|
25
|
+
|
|
26
|
+
The domain is registered **on the workspace** (`@ossy/apps/actions/register-domain` / Domains UI). It is **not** a parameter the ingest action trusts from the client.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## One write path
|
|
31
|
+
|
|
32
|
+
There is a single ingest: `@ossy/analytics/actions/create-page-view` (`access: 'public'`).
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
browser → POST /actions { action: create-page-view, payload: { path, visitorId, … } }
|
|
36
|
+
→ resolve Host
|
|
37
|
+
→ lookup managed domain
|
|
38
|
+
→ registered: page-view resource (belongsTo = that workspace, content.host = Host)
|
|
39
|
+
→ unknown / local: 200 `{ ok: true, recorded: false }` (no write)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
No second event type, no parallel store, no member-session ingest, no baked `config.workspaceId` fallback.
|
|
43
|
+
|
|
44
|
+
### Resolve Host
|
|
45
|
+
|
|
46
|
+
| Request | Host |
|
|
47
|
+
|---|---|
|
|
48
|
+
| Same-origin (page on `ossy.se` posts to `ossy.se`) | `req.hostname` |
|
|
49
|
+
| Cross-origin (page on `customer.com` posts to Ossy `/actions`) | Browser `Origin` hostname (fallback `Referer`) |
|
|
50
|
+
|
|
51
|
+
Strip port and a leading `www.`. `localhost` / loopback are Hosts **if registered** (needed for local app-test). Unregistered local Hosts are the same 200 no-op as any unknown Host.
|
|
52
|
+
|
|
53
|
+
### Lookup
|
|
54
|
+
|
|
55
|
+
Managed-domain with hostname on `state.name` **or** `state.content.Domain` / `state.content.domain` (apex or `www.`), not removed. `belongsTo` is the workspace that registered it. Document display name may differ from the Domain field — both must resolve the same Host.
|
|
56
|
+
|
|
57
|
+
Unknown or local unregistered Host → **200 no-op** (`recorded: false`). Do **not** return 400/404 — that would enumerate the registry. Do not fall back to `req.workspaceId`, cookies, or app config.
|
|
58
|
+
|
|
59
|
+
### Persist
|
|
60
|
+
|
|
61
|
+
Page views are **resources**. When the view happened is resource `created` (envelope, unix ms, stamped at ingest). Do not duplicate that as `content.eventAt`. Stats window and daily buckets use `state.created`.
|
|
62
|
+
|
|
63
|
+
Content **must** include `host` (the resolved hostname). Also: `path`, `visitorId`, optional `section` / `language` / `referrer` / `userAgent`.
|
|
64
|
+
|
|
65
|
+
After Host allowlist, persist a page-view resource. Trusted connection `ip` is stamped on the **eventstore row** (ADR 0016), never `payload.ip`. Do **not** GeoIP at ingest — `countryCode` is a projection field. Do **not** list `ip` / `countryCode` on the page-view schema.
|
|
66
|
+
|
|
67
|
+
Drop client `workspaceId`, `ip`, `countryCode`, client `host`, client `eventAt`, and any other extras. `createdBy` is `req.userId` or `public`. Resource `name` is the path.
|
|
68
|
+
|
|
69
|
+
`visitorId` is a stable anonymous id from the client (localStorage). Unique visitors = distinct `visitorId` per Host in the window. Signed-in users keep the same visitor id in that browser.
|
|
70
|
+
|
|
71
|
+
Location stays `/@ossy/analytics/page-views/`. `belongsTo` is the registry workspace so ACL/stats stay tenant-scoped.
|
|
72
|
+
|
|
73
|
+
### Tracker
|
|
74
|
+
|
|
75
|
+
`PageViewTracker` lives in the app shell and invokes create-page-view on every client navigation (no `GetWorkspace`). Signed-in product routes on a registered Host count as that Host’s traffic. Failures are silent. A site that is not a registered Host is a no-op (`recorded: false`).
|
|
76
|
+
|
|
77
|
+
External / non-platform sites use the same action: POST `/actions` from their origin. Attribution is `Origin`, which must match a managed domain.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Read path
|
|
82
|
+
|
|
83
|
+
`@ossy/analytics/actions/get-page-view-stats` remains `access: 'workspace'`.
|
|
84
|
+
|
|
85
|
+
**Required:** `host` (or `domain`) in the payload. Stats match `belongsTo` = current workspace **and** `content.host` = that Host, filtered by resource `created` in the window. The Host must be registered on this workspace; otherwise **404** (same as unknown — do not distinguish “exists on another tenant”).
|
|
86
|
+
|
|
87
|
+
**Time window:** filter and bucket on resource envelope `state.created` (server clock), not client `content.eventAt`.
|
|
88
|
+
|
|
89
|
+
**Returns:** `host`, `from`, `to`, `uniqueVisitors`, `totalViews`, `dailyViews[]`, `topPaths[]`, `byCountry[]` (default last 30 days). `byCountry` comes from `PageViewLocationProjection` (MaxMind), not from write-event `countryCode`.
|
|
90
|
+
|
|
91
|
+
`/analytics` (`analytics/home`):
|
|
92
|
+
|
|
93
|
+
1. Signed out or not entitled → sales page (unchanged).
|
|
94
|
+
2. Entitled, **no** registered domains:
|
|
95
|
+
- Domains package **not** entitled → CTA to enable Domains (`packages/detail` for `domains`). Do not open generic create under `/@ossy/domains/`.
|
|
96
|
+
- Domains entitled → empty state: add a domain (link to Domains home). Do not load member/resource KPIs. Do not query stats. `data-analytics-overview-status="empty"`.
|
|
97
|
+
3. Entitled, one or more domains → **must pick a Host** (query/select). Then show visitors / views / top paths / daily / locations for that Host.
|
|
98
|
+
|
|
99
|
+
Analytics **requires a domain** in the product sense: no Host, no traffic UI. Enabling the package without a domain is allowed; the home page tells you to register one.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Multiple domains
|
|
104
|
+
|
|
105
|
+
One workspace may register `example.com` and `blog.example.com`. Same write. Events differ by `content.host`; both `belongsTo` that workspace.
|
|
106
|
+
|
|
107
|
+
The dashboard does not sum Hosts together. Pick one. (Later: optional roll-up is a new read, not a second write.)
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Workspace KPIs (not analytics)
|
|
112
|
+
|
|
113
|
+
Member count and resource count are workspace facts, not Host traffic.
|
|
114
|
+
|
|
115
|
+
| Today | Target |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `@ossy/analytics/actions/get-workspace-kpis` on `/analytics` | Same action on **workspace settings** (`workspace/settings`, `/settings`) |
|
|
118
|
+
|
|
119
|
+
`@ossy/analytics` does not show members/resources. Sales copy for analytics is about site visitors, not seat counts.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Entitlements
|
|
124
|
+
|
|
125
|
+
| Package | Role |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `@ossy/domains` | Register Hosts (`/@ossy/domains/`). Analytics must not create documents in that namespace when Domains is off. |
|
|
128
|
+
| `@ossy/analytics` | View Host traffic (`/@ossy/analytics/page-views/`) |
|
|
129
|
+
|
|
130
|
+
Ingest does **not** require analytics entitlement — a registered Host is enough to accept views. **Reading** stats requires analytics entitled + membership on the workspace that owns the Host.
|
|
131
|
+
|
|
132
|
+
Do not add a second ingest gated on entitlement.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Locations (#769)
|
|
137
|
+
|
|
138
|
+
| Layer | Behaviour |
|
|
139
|
+
|---|---|
|
|
140
|
+
| Ingest | After Host allowlist only: append the page-view event. Connection `ip` is stamped on the eventstore row (ADR 0016). No MaxMind at write. Unknown Host → no write. |
|
|
141
|
+
| Projection | `PageViewLocationProjection` (`@ossy/analytics/data/page-view-location`), scope `workspaceId:host`, folds `byDay[YYYY-MM-DD][CC]` from MaxMind GeoLite2-Country (`GEOLITE2_COUNTRY_MMDB`). Day from envelope `created`. |
|
|
142
|
+
| Stats | `byCountry` rollup from the Host projection for the window (`countryLimit` separate from top-paths `limit`). |
|
|
143
|
+
| UI | Location tiles / ranked list only — never show raw IP. |
|
|
144
|
+
|
|
145
|
+
## Out of scope
|
|
146
|
+
|
|
147
|
+
- Interactive world map (Phase 0 is ISO tiles + ranked list)
|
|
148
|
+
- Platform-wide visitor totals in the product (ADR 0014 / 0015 — ops Mongo/Grafana)
|
|
149
|
+
- `workspaceId` in website `src/config.js` as a substitute for Host registration
|
|
150
|
+
- Bot filtering, sampling, or rate limits (follow-up)
|
|
151
|
+
- Summing multiple Hosts on one chart
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Implementation notes (current vs this spec)
|
|
156
|
+
|
|
157
|
+
|#797 / previous | This spec |
|
|
158
|
+
|---|---|
|
|
159
|
+
| Host lookup, then cookie `workspaceId`, then `config.workspaceId` | Host lookup only; else 200 no-op |
|
|
160
|
+
| Unknown Host → 400 | Unknown Host → 200 `{ recorded: false }` (no registry leak) |
|
|
161
|
+
| Stats unfiltered by `host`; window on `content.eventAt` | Stats require `host`; window and daily buckets on resource `created` |
|
|
162
|
+
| `/analytics` shows members + resources + all workspace views | Host picker + traffic only; KPIs on `/settings` |
|
|
163
|
+
| Page-view schema has `eventAt`, no `host` | Required `host`; no `eventAt` — envelope `created` is the clock |
|
|
164
|
+
| Signed-in app navigations excluded (or attributed to the session workspace) | Same Host ingest for anonymous and signed-in |
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## DoD
|
|
169
|
+
|
|
170
|
+
- [ ] `create-page-view` public; Host from hostname/`Origin`; unknown/local Host → 200 no-op; no cookie/config fallback
|
|
171
|
+
- [ ] Page-view events store `host`; client cannot set `belongsTo` or `eventAt`; when = resource `created`
|
|
172
|
+
- [ ] `get-page-view-stats` requires a Host registered on the current workspace; filters by `state.created`
|
|
173
|
+
- [ ] `/analytics` empty without a domain; with domains, picker + unique visitors / views for the selected Host
|
|
174
|
+
- [ ] Member/resource counts on workspace settings, not analytics home
|
|
175
|
+
- [ ] Cross-origin POST with `Origin: https://{registered-host}` attributes to that Host’s workspace
|
|
176
|
+
- [ ] Signed-in member on another workspace visiting `ossy.se` counts on Ossy’s Host, not the member’s workspace
|
|
177
|
+
- [ ] Registered Host ingest stamps `event.ip`; projection derives `countryCode`; `/analytics` shows `byCountry` for the selected Host (no IP in UI)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/analytics",
|
|
3
|
-
"description": "Analytics module —
|
|
4
|
-
"version": "3.
|
|
3
|
+
"description": "Analytics module — Host visitor traffic for registered domains",
|
|
4
|
+
"version": "3.12.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -22,16 +22,21 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"/src",
|
|
25
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"SPEC.md"
|
|
26
27
|
],
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"@ossy/event-store": "^3.
|
|
29
|
-
"@ossy/
|
|
29
|
+
"@ossy/event-store": "^3.12.0",
|
|
30
|
+
"@ossy/resources": "^3.12.0",
|
|
31
|
+
"@ossy/workspaces": "^3.12.0",
|
|
32
|
+
"maxmind": "^5.0.7",
|
|
33
|
+
"nanoid": "^5.1.11"
|
|
30
34
|
},
|
|
31
35
|
"peerDependencies": {
|
|
32
36
|
"@ossy/app": "*",
|
|
33
37
|
"@ossy/authentication": "*",
|
|
34
38
|
"@ossy/design-system": "*",
|
|
39
|
+
"@ossy/domains": "*",
|
|
35
40
|
"@ossy/resources": "*",
|
|
36
41
|
"@ossy/router-react": "*",
|
|
37
42
|
"@ossy/sdk-react": "*",
|
|
@@ -42,5 +47,5 @@
|
|
|
42
47
|
"@jest/globals": "^30.2.0",
|
|
43
48
|
"jest": "^30.2.0"
|
|
44
49
|
},
|
|
45
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "c2650803219ad1831559b512848358d9550ffad2"
|
|
46
51
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import React, { useMemo } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { ListResources } from '@ossy/resources'
|
|
3
|
+
import { location as domainsLocation, DomainsSchema } from '@ossy/domains'
|
|
4
|
+
import { AsyncStatus, useSdk } from '@ossy/sdk-react'
|
|
5
|
+
import { useRouter } from '@ossy/router-react'
|
|
6
|
+
import { Text, View, Page, Button, Select, useLocale } from '@ossy/design-system'
|
|
7
|
+
import { isServiceEntitled } from '@ossy/workspaces/entitlements'
|
|
3
8
|
import { usePageViewStats } from './usePageViewStats.js'
|
|
4
|
-
import {
|
|
9
|
+
import { hostFromDomainResource } from './host-from-domain-resource.js'
|
|
10
|
+
|
|
11
|
+
const DOMAINS_SERVICE = '@ossy/domains'
|
|
5
12
|
|
|
6
13
|
function SectionPanel ({ title, children }) {
|
|
7
14
|
return (
|
|
@@ -38,70 +45,114 @@ function SummaryCard ({ label, value }) {
|
|
|
38
45
|
)
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
function overviewStatusFrom ({ listStatus, hostCount, statsStatus }) {
|
|
49
|
+
if (listStatus === AsyncStatus.Error || listStatus === AsyncStatus.AuthenticationError) return 'error'
|
|
50
|
+
if (listStatus === AsyncStatus.Loading || listStatus === AsyncStatus.NotInitialized) return 'loading'
|
|
51
|
+
if (hostCount === 0) return 'empty'
|
|
52
|
+
if (statsStatus === 'error') return 'error'
|
|
53
|
+
if (statsStatus === 'success') return 'ready'
|
|
54
|
+
return 'loading'
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* Workspace comes from the page (shell snapshot) — do not re-read GetWorkspace.
|
|
58
|
+
* Host-traffic analytics. Only mount when the caller is authenticated
|
|
59
|
+
* and entitled. Workspace comes from the page (shell snapshot).
|
|
45
60
|
*/
|
|
46
61
|
export default function AnalyticsProductHome ({ workspace }) {
|
|
47
62
|
const { t } = useLocale()
|
|
63
|
+
const router = useRouter()
|
|
64
|
+
const sdk = useSdk()
|
|
48
65
|
const { from, to } = useMemo(() => {
|
|
49
66
|
const now = Date.now()
|
|
50
67
|
return { from: now - 30 * 24 * 60 * 60 * 1000, to: now }
|
|
51
68
|
}, [])
|
|
52
69
|
|
|
70
|
+
const { status: listStatus, data: resources = [] } = sdk.read(ListResources, { location: domainsLocation })
|
|
71
|
+
const hosts = useMemo(() => {
|
|
72
|
+
const seen = new Set()
|
|
73
|
+
for (const resource of resources || []) {
|
|
74
|
+
if (resource?.type !== DomainsSchema.managedDomain) continue
|
|
75
|
+
const host = hostFromDomainResource(resource)
|
|
76
|
+
if (host) seen.add(host)
|
|
77
|
+
}
|
|
78
|
+
return [...seen]
|
|
79
|
+
}, [resources])
|
|
80
|
+
|
|
81
|
+
const requestedHost = String(router.searchParams?.host || '').trim().toLowerCase().replace(/^www\./, '')
|
|
82
|
+
const host = hosts.includes(requestedHost) ? requestedHost : (hosts[0] || '')
|
|
83
|
+
|
|
53
84
|
const pageViews = usePageViewStats({
|
|
54
85
|
workspaceId: workspace?.id,
|
|
86
|
+
host,
|
|
55
87
|
from,
|
|
56
88
|
to,
|
|
57
89
|
limit: 10,
|
|
58
90
|
})
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
|
|
92
|
+
const { status, totalViews, uniqueVisitors, dailyViews, topPaths, byCountry } = pageViews
|
|
93
|
+
const maxCountryViews = byCountry.reduce((max, item) => Math.max(max, item.views || 0), 0)
|
|
94
|
+
const overviewStatus = overviewStatusFrom({
|
|
95
|
+
listStatus,
|
|
96
|
+
hostCount: hosts.length,
|
|
97
|
+
statsStatus: status,
|
|
98
|
+
})
|
|
99
|
+
const domainsEntitled = isServiceEntitled(workspace?.services, DOMAINS_SERVICE)
|
|
100
|
+
const domainsHref = domainsEntitled
|
|
101
|
+
? (router.getHref('domains/home') || '/domains')
|
|
102
|
+
: (router.getHref({ id: 'packages/detail', params: { packageSlug: 'domains' } }) || '/packages/domains')
|
|
103
|
+
const emptyCopy = domainsEntitled ? 'analytics.home.empty' : 'analytics.home.emptyEnableDomains'
|
|
104
|
+
const emptyCta = domainsEntitled ? 'analytics.home.addDomain' : 'analytics.home.enableDomains'
|
|
105
|
+
|
|
106
|
+
const selectHost = (nextHost) => {
|
|
107
|
+
if (typeof router.searchParams?.patch === 'function') {
|
|
108
|
+
router.searchParams.patch({ host: nextHost || null })
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (nextHost) router.searchParams.set('host', nextHost)
|
|
112
|
+
else router.searchParams.delete('host')
|
|
113
|
+
}
|
|
70
114
|
|
|
71
115
|
return (
|
|
72
116
|
<Page
|
|
73
117
|
data-analytics-status="on"
|
|
74
118
|
data-analytics-overview-status={overviewStatus}
|
|
119
|
+
data-analytics-host={host || undefined}
|
|
75
120
|
title="analytics.home.title"
|
|
76
121
|
description="analytics.home.description"
|
|
122
|
+
far={hosts.length > 0 ? (
|
|
123
|
+
<Select
|
|
124
|
+
data-analytics-host-picker
|
|
125
|
+
aria-label={t('analytics.home.hostLabel')}
|
|
126
|
+
value={host}
|
|
127
|
+
onChange={(event) => selectHost(event.target.value)}
|
|
128
|
+
>
|
|
129
|
+
{hosts.map((item) => (
|
|
130
|
+
<option key={item} value={item}>{item}</option>
|
|
131
|
+
))}
|
|
132
|
+
</Select>
|
|
133
|
+
) : undefined}
|
|
77
134
|
>
|
|
78
135
|
<View gap="m">
|
|
79
|
-
{
|
|
80
|
-
<SectionPanel title="analytics.home.
|
|
136
|
+
{overviewStatus === 'loading' && (
|
|
137
|
+
<SectionPanel title="analytics.home.traffic">
|
|
81
138
|
<View inset="m">
|
|
82
139
|
<Text>{t('analytics.home.loading')}</Text>
|
|
83
140
|
</View>
|
|
84
141
|
</SectionPanel>
|
|
85
142
|
)}
|
|
86
143
|
|
|
87
|
-
{
|
|
88
|
-
<SectionPanel title="analytics.home.
|
|
89
|
-
<View inset="m">
|
|
90
|
-
<Text>{t(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
{!pageLoading && workspaceKpis.status === 'success' && (
|
|
96
|
-
<SectionPanel title="analytics.home.overview">
|
|
97
|
-
<View layout="row-wrap" gap="m" inset="m">
|
|
98
|
-
<SummaryCard label={t('analytics.home.members')} value={`${workspaceKpis.members}`} />
|
|
99
|
-
<SummaryCard label={t('analytics.home.resources')} value={`${workspaceKpis.resources}`} />
|
|
144
|
+
{overviewStatus === 'empty' && (
|
|
145
|
+
<SectionPanel title="analytics.home.traffic">
|
|
146
|
+
<View gap="m" inset="m">
|
|
147
|
+
<Text>{t(emptyCopy)}</Text>
|
|
148
|
+
<Button variant="cta" href={domainsHref} data-analytics-add-domain>
|
|
149
|
+
{t(emptyCta)}
|
|
150
|
+
</Button>
|
|
100
151
|
</View>
|
|
101
152
|
</SectionPanel>
|
|
102
153
|
)}
|
|
103
154
|
|
|
104
|
-
{
|
|
155
|
+
{overviewStatus === 'error' && (
|
|
105
156
|
<SectionPanel title="analytics.home.traffic">
|
|
106
157
|
<View inset="m">
|
|
107
158
|
<Text>{t('analytics.home.error')}</Text>
|
|
@@ -109,10 +160,11 @@ export default function AnalyticsProductHome ({ workspace }) {
|
|
|
109
160
|
</SectionPanel>
|
|
110
161
|
)}
|
|
111
162
|
|
|
112
|
-
{
|
|
163
|
+
{overviewStatus === 'ready' && (
|
|
113
164
|
<>
|
|
114
165
|
<SectionPanel title="analytics.home.traffic">
|
|
115
166
|
<View layout="row-wrap" gap="m" inset="m">
|
|
167
|
+
<SummaryCard label={t('analytics.home.uniqueVisitors')} value={`${uniqueVisitors}`} />
|
|
116
168
|
<SummaryCard label={t('analytics.home.totalPageViews')} value={`${totalViews}`} />
|
|
117
169
|
<SummaryCard label={t('analytics.home.trackedPages')} value={`${topPaths.length}`} />
|
|
118
170
|
<SummaryCard label={t('analytics.home.daysWithTraffic')} value={`${dailyViews.length}`} />
|
|
@@ -148,6 +200,60 @@ export default function AnalyticsProductHome ({ workspace }) {
|
|
|
148
200
|
</SectionPanel>
|
|
149
201
|
</View>
|
|
150
202
|
</View>
|
|
203
|
+
|
|
204
|
+
<SectionPanel title="analytics.home.locations">
|
|
205
|
+
<View gap="s" inset="m">
|
|
206
|
+
{byCountry.length === 0 && (
|
|
207
|
+
<Text variant="small" text="analytics.home.noLocationData" />
|
|
208
|
+
)}
|
|
209
|
+
{byCountry.length > 0 && (
|
|
210
|
+
<View
|
|
211
|
+
layout="row-wrap"
|
|
212
|
+
gap="s"
|
|
213
|
+
data-analytics-locations-map
|
|
214
|
+
style={{ marginBottom: 'var(--space-m)' }}
|
|
215
|
+
>
|
|
216
|
+
{byCountry.map((item) => {
|
|
217
|
+
const intensity = maxCountryViews
|
|
218
|
+
? Math.max(0.18, item.views / maxCountryViews)
|
|
219
|
+
: 0.18
|
|
220
|
+
return (
|
|
221
|
+
<View
|
|
222
|
+
key={item.countryCode}
|
|
223
|
+
data-country={item.countryCode}
|
|
224
|
+
style={{
|
|
225
|
+
minWidth: 56,
|
|
226
|
+
padding: 'var(--space-s)',
|
|
227
|
+
borderRadius: 'var(--roundness-xs)',
|
|
228
|
+
background: `color-mix(in srgb, var(--color-primary) ${Math.round(intensity * 100)}%, transparent)`,
|
|
229
|
+
textAlign: 'center',
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
<Text variant="small" style={{ fontWeight: 'bold' }}>{item.countryCode}</Text>
|
|
233
|
+
<Text variant="small">{item.views}</Text>
|
|
234
|
+
</View>
|
|
235
|
+
)
|
|
236
|
+
})}
|
|
237
|
+
</View>
|
|
238
|
+
)}
|
|
239
|
+
{byCountry.map((item) => (
|
|
240
|
+
<View key={`row-${item.countryCode}`} layout="row" justifyContent="space-between" gap="m">
|
|
241
|
+
<Text variant="small">{item.countryCode}</Text>
|
|
242
|
+
<View style={{ flex: 1, minWidth: 0, alignSelf: 'center' }}>
|
|
243
|
+
<View
|
|
244
|
+
style={{
|
|
245
|
+
height: 6,
|
|
246
|
+
borderRadius: 999,
|
|
247
|
+
background: 'var(--color-primary)',
|
|
248
|
+
width: `${maxCountryViews ? Math.max(8, (item.views / maxCountryViews) * 100) : 8}%`,
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
</View>
|
|
252
|
+
<Text variant="small">{item.views}</Text>
|
|
253
|
+
</View>
|
|
254
|
+
))}
|
|
255
|
+
</View>
|
|
256
|
+
</SectionPanel>
|
|
151
257
|
</>
|
|
152
258
|
)}
|
|
153
259
|
</View>
|
package/src/Definition.js
CHANGED
|
@@ -31,14 +31,14 @@ export function useAnalyticsHomeContent () {
|
|
|
31
31
|
|
|
32
32
|
const features = [
|
|
33
33
|
{
|
|
34
|
-
title: t('analytics.home.features.
|
|
34
|
+
title: t('analytics.home.features.visitors.title'),
|
|
35
35
|
icon: 'user',
|
|
36
|
-
text: t('analytics.home.features.
|
|
36
|
+
text: t('analytics.home.features.visitors.text'),
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
|
-
title: t('analytics.home.features.
|
|
39
|
+
title: t('analytics.home.features.paths.title'),
|
|
40
40
|
icon: 'folder',
|
|
41
|
-
text: t('analytics.home.features.
|
|
41
|
+
text: t('analytics.home.features.paths.text'),
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
title: t('analytics.home.features.traffic.title'),
|
package/src/analytics.page.jsx
CHANGED
|
@@ -15,7 +15,7 @@ export const metadata = {
|
|
|
15
15
|
const ANALYTICS_SERVICE = '@ossy/analytics'
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Analytics home —
|
|
18
|
+
* Analytics home — Host traffic when signed in and entitled, otherwise sales.
|
|
19
19
|
* Entitlement comes from the shell workspace snapshot (SSR bootstrap / cookie)
|
|
20
20
|
* so the dashboard does not flash the sales page on navigation.
|
|
21
21
|
*/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const metadata = { id: '@ossy/analytics/actions/create-page-view', access: 'public' }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { Workspace } from '@ossy/workspaces/server'
|
|
4
|
+
import { ResourcesEvents, commitResource, ResourceStream } from '@ossy/resources/server'
|
|
5
|
+
import {
|
|
6
|
+
hostFromRequest,
|
|
7
|
+
pageViewContentFromPayload,
|
|
8
|
+
resolvePageViewWorkspaceId,
|
|
9
|
+
workspaceIdForRegisteredHost,
|
|
10
|
+
} from './resolve-page-view-workspace.js'
|
|
11
|
+
|
|
12
|
+
export const metadata = { id: '@ossy/analytics/tasks/create-page-view' }
|
|
13
|
+
|
|
14
|
+
const PAGE_VIEW_SCHEMA_ID = '@ossy/web/schema/page-view'
|
|
15
|
+
const PAGE_VIEW_LOCATION = '/@ossy/analytics/page-views/'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Public ingest. Unknown Host is a silent no-op (200) so the
|
|
19
|
+
* managed-domain registry cannot be enumerated.
|
|
20
|
+
*
|
|
21
|
+
* After Host allowlist: append a page-view resource. Connection `ip` is stamped
|
|
22
|
+
* on the eventstore row (ADR 0016), not on content. `countryCode` is derived
|
|
23
|
+
* later by PageViewLocationProjection (MaxMind).
|
|
24
|
+
*/
|
|
25
|
+
export async function run ({ payload, req }) {
|
|
26
|
+
const host = hostFromRequest(req)
|
|
27
|
+
const workspaceId = await resolvePageViewWorkspaceId({
|
|
28
|
+
host,
|
|
29
|
+
findDomain: (name) => workspaceIdForRegisteredHost(ResourceStream.Collection, name),
|
|
30
|
+
})
|
|
31
|
+
if (!workspaceId) return { ok: true, recorded: false }
|
|
32
|
+
|
|
33
|
+
const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
|
|
34
|
+
|
|
35
|
+
const contentInput = pageViewContentFromPayload(payload, {
|
|
36
|
+
userAgent: req?.headers?.['user-agent'],
|
|
37
|
+
path: '/',
|
|
38
|
+
host,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const resourceId = nanoid()
|
|
42
|
+
const event = ResourcesEvents.Created({
|
|
43
|
+
resourceId,
|
|
44
|
+
schemaId: PAGE_VIEW_SCHEMA_ID,
|
|
45
|
+
createdBy: req?.userId ?? 'public',
|
|
46
|
+
belongsTo: workspace.id,
|
|
47
|
+
location: PAGE_VIEW_LOCATION,
|
|
48
|
+
name: contentInput.path || '/',
|
|
49
|
+
content: contentInput,
|
|
50
|
+
access: 'restricted',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const resource = await commitResource(event)
|
|
54
|
+
return { ok: true, recorded: true, id: resource.id }
|
|
55
|
+
}
|