Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ jobs:
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10.32.1
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
Expand Down
77 changes: 60 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

Native EmDash CMS integration for OpenAnalytics, by Black Swamp AI.

This initial scaffold provides configuration and connection validation, encrypted
server-side credential storage through EmDash, and public-site tracker installation.
Embedded analytics UI is planned and is outside this release.
Native connection validation, public-site tracker installation, and an analytics
overview inside EmDash. The admin page uses EmDash Block Kit controls, metric
cards, notices, and a timeseries chart. Private credentials stay on the server.

## Installation

Requires EmDash 1.0.1 or later in the 1.x series and Node.js 22.16 or later.
This scaffold has not been published to npm. For local testing, run `pnpm install`
This package has not been published to npm. For local testing, run `pnpm install`
and `pnpm build` in this checkout, then install it from your EmDash site:

```sh
Expand Down Expand Up @@ -50,18 +50,21 @@ render the tracker. See [EmDash's page fragment guide](https://docs.emdashcms.co
## OpenAnalytics setup

1. Create a **private read key** in OpenAnalytics for the site you want to track.
Request `site:read` and `analytics:read`. This scaffold uses only `site:read`;
`analytics:read` prepares the key for the future embedded analytics UI.
Request `site:read` and `analytics:read`. Connection validation uses `site:read`;
the admin overview uses `analytics:read`.
Older keys may only have `site:read`; site validation cannot verify the extra scope.
2. Configure `EMDASH_ENCRYPTION_KEY` on the EmDash server **before saving a key**.
Follow [EmDash's secrets and key management guide](https://docs.emdashcms.com/deployment/secrets/).
3. Open **Plugins**, then the settings control for this plugin. Save the API URL,
private read key, and tracking switch. Tracking defaults to enabled, but no
tracker appears before a successful connection validation.
4. Validate the saved connection with the authenticated server route below.
private read key, tracking switch, and analytics timezone. Set the timezone
to your site's IANA timezone, such as `America/New_York`. It defaults to UTC;
EmDash's native plugin context does not expose the host site's timezone.
Tracking defaults to enabled, but no tracker appears before successful validation.
4. Open **OpenAnalytics** in EmDash's plugin navigation and click **Validate connection**.
The page shows the connected site, tracking readiness, API URL, and last
validation time. Use **Revalidate connection** after rotating tracker settings.

The initial scaffold exposes a server route instead of a setup wizard. From the
browser console on your EmDash admin page, while signed in as an administrator:
The existing protected validation route also remains available to administrators:

```js
const response = await fetch("/_emdash/api/plugins/emdash-openanalytics/validate-connection", {
Expand All @@ -76,11 +79,48 @@ you do not pass the key in this request. EmDash wraps the plugin's result in its
standard API response envelope. Connection failures contain safe error details.
Successful validation reports site identity, status, and tracker readiness.

## Analytics overview

The OpenAnalytics page shows visitors, pageviews, and events from the aggregate
overview response, plus visitors and pageviews over time. Choose **Last 24 hours**,
**Last 7 days**, **Last 30 days** (default), or **Last 90 days**. Requests send
explicit UTC bounds and the configured IANA timezone. Overview totals use hourly
rollups; the chart uses hourly buckets for 24 hours or daily buckets for longer
ranges. Visitor buckets are displayed
as returned, never summed into the aggregate visitor metric.

Metric cards show previous-period totals when supplied by OpenAnalytics. These
are server-provided aggregate comparisons; the plugin does not calculate
percentages or infer comparisons from chart buckets.

OpenAnalytics can snap bounds down to available rollup boundaries; the page
shows the effective queried periods. Chart buckets follow the configured
timezone, while native chart tick labels and tooltips use the browser timezone.

Freshness information shows the latest rolled-up data and pipeline status.
Stale, degraded, imported, or partial results receive notices so temporarily low
numbers are easier to interpret. Missing freshness is shown as unavailable.
Authentication failures, missing analytics scope, suspended service, rate limits,
and unavailable upstream service have safe messages and validation/retry controls.

Opening the page or changing its range makes two server-side reads: overview and
timeseries. Revalidation first reads site metadata and refreshes the installation
snapshot. There is no polling, automatic retry, or shared analytics cache. The
private admin route requires `plugins:manage` and EmDash's CSRF protection.

Validation saves the returned public installation configuration. Public page
requests use this saved configuration without calling the OpenAnalytics read API.
Changing the API URL or private key stops injection until you validate again.
A failed validation clears the saved connection. Turning tracking off suppresses
injection immediately; turning it on uses the existing valid connection.
The snapshot is bound to the exact normalized API URL and private key. A
different configuration suppresses tracking; restoring the exact validated
configuration makes its matching snapshot usable again. Removing the private
key or turning tracking off suppresses injection immediately.

Temporary network failures, timeouts, rate limits, and service errors during
revalidation preserve a matching last-known-good snapshot. The admin shows the
error while tracking continues from that snapshot. Rejected credentials and
invalid installation responses invalidate the connection. Successful validation
replaces the saved snapshot. A snapshot never enables tracking for a different
API URL or credential.

## Security

Expand Down Expand Up @@ -112,7 +152,8 @@ manual tracker URL overrides.

## Current limitations

- No analytics dashboard, custom admin page, automatic refresh, polling, or retries.
- The overview is intentionally small: no top pages, sources, sessions, funnels,
revenue, visitor profiles, editor analytics, or realtime polling.
- Revalidate after tracker rotation or a collector URL change. Saved installation
metadata has no automatic expiry; private-key revocation is detected on validation.
- Static pages receive the snapshot available when they are rendered. Rebuild
Expand All @@ -133,6 +174,8 @@ pnpm check
verification. CI runs the same checks. No npm publication is performed.

Source boundaries are the native plugin entry, settings/configuration, the
server-side OpenAnalytics client, saved connection state, and tracker fragments.
server-side OpenAnalytics client, saved connection state, tracker fragments,
and native admin page.
See [verified upstream contracts](docs/upstream-contracts.md) for versions and
the native-plugin/security decisions. MIT licensed; no OpenAnalytics source is bundled.
the native-plugin/security decisions and [implementation footprint](docs/implementation-footprint.md)
for the comparison with the n8n integration. MIT licensed; no OpenAnalytics source is bundled.
45 changes: 45 additions & 0 deletions docs/implementation-footprint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Implementation footprint

Measured on 2026-09-29. Counts are physical TypeScript lines, including comments
and blank lines, using checked-in source rather than build output. Production
counts include `src/**/*.ts` for EmDash and `nodes/**/*.ts` plus
`credentials/**/*.ts` for n8n. Test counts include `tests/**/*.ts`, including
fixtures/helpers. Lockfiles, generated code, docs, CI, and package tooling are
excluded. These are size comparisons, not runtime performance measurements.

| Implementation | Production LOC | Test LOC | Production modules | OpenAnalytics HTTP endpoints |
| ----------------------------- | -------------: | -------: | -----------------: | ---------------------------: |
| EmDash scaffold, PR #1 | 495 | 721 | 9 | 1 |
| EmDash native overview, PR #2 | 1458 | 1923 | 10 | 3 |
| n8n OpenAnalytics 0.1.1 | 607 | 613 | 5 | 11 |

PR #2 adds **963 production lines** and **1202 test lines**
over the scaffold. The scaffold tree at `a9e69a2` is identical to the original
`c7c8efe` tree. The comparison uses n8n commit
`8caaa20c6385f6fba1fcf2e8c2dbec0a1ac5efb1` from the local
`@blackswampai/n8n-nodes-openanalytics` checkout.

The EmDash plugin now owns a native admin page, public tracker insertion,
credential-bound installation snapshots, secure read transport, response
validation/projection, and useful connection/error/freshness states. The n8n
package exposes a broader set of declarative read operations through n8n's
workflow editor; it does not install a public tracker or render a site overview.
The size difference reflects those different responsibilities.

EmDash production modules remain narrowly scoped: plugin/descriptor, connection
state, configuration/settings, OpenAnalytics transport/errors/types, tracker
fragment, and one admin page. PR #2 adds no production dependencies, custom
browser bundle, chart library, application framework, polling, or shared cache.
`@emdash-cms/blocks@1.0.1` is a development dependency for type-only authoring and
upstream response validation in tests; EmDash provides its renderer at runtime.

Analytics use exactly two HTTP reads per requested overview: overview (including
its server-provided preceding-period totals) and timeseries. Revalidation adds
one site read. Public page rendering adds no read-key requests. A transient
revalidation failure returns its error and preserves a matching tracker
snapshot without starting additional analytics requests.

To reproduce the line counts, enumerate the directories above, include only
`.ts` files, and sum their physical lines. For baseline comparison, read those
same files from `git show a9e69a2:<path>`; for n8n, read files at the pinned commit.
Apply the same formatting/measurement convention in future PRs.
92 changes: 87 additions & 5 deletions docs/upstream-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Inspected on 2026-09-29 before implementation:
core package version 1.0.1. Tests and build use the published `emdash@1.0.1`.
- OpenAnalytics [`f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9`](https://github.com/OpenLabs-so/openanalytics/tree/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9).

Rechecked for the native admin PR using current official docs, installed package
source, and `git ls-remote HEAD` for both upstream repositories. These remain
the current upstream HEAD revisions. Search engine commit listings can be stale;
the contracts below follow the source at those revisions.

## EmDash

The [native-plugin tutorial](https://docs.emdashcms.com/plugins/creating-native-plugins/your-first-native-plugin/)
Expand Down Expand Up @@ -36,10 +41,31 @@ authentication, CSRF checks, and response envelopes. This plugin uses a private
POST route with `plugins:manage`.

[Block Kit](https://docs.emdashcms.com/plugins/creating-plugins/block-kit/)
is supported by native plugins through an admin interaction route, declared page
or widget metadata, and JSON block responses. Native React pages/widgets require
separate descriptor/runtime entries and an admin module. Neither is needed for
this scaffold; Block Kit remains a suitable candidate for PR #2.
supports native plugins with `admin.pages`, a private POST `admin` route, and
JSON `BlockResponse` results. No admin entry module or browser plugin code is
needed. The host posts `page_load` with `page`, or `block_action` with `action_id`,
`value`, and `page`; replacement blocks update the interface. This plugin checks
the declared page and allowed actions/ranges before any upstream request.

The published `@emdash-cms/blocks@1.0.1` types define headers, fields, actions,
selects/buttons, stats, banners, context, and a native timeseries chart with
`config.chart_type: "timeseries"` and `[timestamp_ms, value]` points. The host
owns chart rendering, typography, spacing, navigation, and initial loading.
Forms, tables (including badge cells), and tabs are available but unnecessary
for this page. There is no standalone status badge or plugin-owned loading
block; banners/fields represent connection state.

Only Block Kit **types** are imported in production; the blocks package is a
development dependency and its React/chart renderer is not bundled. Tests use
the upstream block validator because trusted native responses do not receive
the sandboxed response validation policy automatically.

Installed runtime source `EmDashRuntime.resolveTrustedUiContext` supplies native
admin locale/direction for declared pages. Neither `ctx.ui` nor `ctx.site`
includes a timezone. Plugin settings are scoped to the plugin; the host
`site:timezone` is not exposed through them. The overview therefore provides a
small IANA timezone setting, defaults to UTC, and displays the timezone. It does
not query internal host tables or infer timezone from content locale.

Upstream tests use Vitest. The published
`emdash/internal/plugin-test-runtime` exposes the runtime, route dispatcher, and
Expand All @@ -52,7 +78,7 @@ production code.
The [CMS/WordPress contract](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/docs/wordpress/README.md)
specifies Bearer authentication with a site-bound private read key and the exact
tracker attributes `data-key` and `data-collector`.
`GET /v1/read/site` requires `site:read`. Future analytics reads require
`GET /v1/read/site` requires `site:read`. Analytics reads require
`analytics:read`, which older/default keys do not necessarily carry.

Verified implementation: `apps/api/src/http/read-key.ts`; schema:
Expand All @@ -72,3 +98,59 @@ model. The current metadata route deliberately permits suspended sites so
integrations can obtain installation details and show status. Analytics reads
have a separate suspended-site gate. The client still normalizes HTTP 402 for
compatibility, along with 401, 403, 404, 429, and service errors.

### Overview and timeseries

Verified against the pinned [OpenAPI schemas](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/packages/contracts/openapi/openapi.yaml)
and [read-key route implementation](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/apps/api/src/http/read-key.ts):

- `GET /v1/read/analytics/overview`: `meta`, aggregate `totals` containing
`visitors`, `pageviews`, `events`, and `billable_events`, and nullable
`comparison`. No sessions, duration, or bounce metric is supplied here.
- `GET /v1/read/analytics/timeseries`: `meta`, `series` of UTC `bucket` instants
with `visitors`, `pageviews`, and `events`, and nullable `comparison`.
- Both require explicit full UTC `from`/`to` instants for a half-open range and
an IANA `timezone`. This plugin explicitly sends `hour` for overview totals
across all four presets, and `hour` for the 24h chart or `day` for 7d/30d/90d.
Timeseries additionally supports `minute`/`week`; overview supports
`hour`/`day`. Automatic grain selection can
produce around 1,440 minute buckets for 24h, so it is deliberately avoided.
- The [aggregate resolver](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/apps/api/src/analytics/resolve.ts)
refuses forced overview `day` for non-UTC timezone offsets. Overview `hour`
reads the quarter-hour atom rollup and supports the full 90d preset (the
current default cap is 400d). Timeseries `day` can compose local days from
that rollup. These endpoints therefore intentionally use separate resolutions.
Effective bounds snap down to rollup boundaries: a UTC daily chart may end at
the last UTC midnight while hourly totals include more recent quarter-hours.
The UI exposes effective queried ranges instead of hiding that difference.
- Aggregate visitors come from overview, never from summing/rebucketing chart
points. Visitor identities rotate at UTC midnight; a visitor can count in
several buckets. Overview requests `compare=true` and displays the server's
preceding-period totals below each metric, with its `comparison_range`.
Timeseries does not request comparison. No client-calculated percentage,
summed bucket total, or separate comparison HTTP request is introduced.
- `meta.freshness` contains `state` (`ok`, `no_data`, `stale`, `degraded`), nullable
`watermark`, and `as_of`. The watermark is the site's latest rolled-up bucket,
potentially outside the requested range; it is not a guarantee that every
event through that instant has arrived. The UI labels it accordingly and
handles absent freshness metadata conservatively.
- `meta.accuracy` distinguishes `exact`, `estimated`, and `provider_defined`;
imported data can affect visitor totals. `partial` and `truncated` also affect
interpretation. These metadata flags receive concise UI notices.
- HTTP 403 `FORBIDDEN` means missing scope; HTTP 403 `SITE_SUSPENDED` means the
site's analytics service is suspended. Only the fixed error code is inspected
to distinguish these states, never the upstream error message. HTTP 402 is
kept as a legacy billing mapping. HTTP 429 carries a `Retry-After` delay;
HTTP 503 denotes service unavailability. Invalid ranges or unsupported grain
can return HTTP 400.

The CMS guide recommends reads on actual admin use and warns against sharing a
read-key response cache across administrators. Each overview interaction makes
only two analytics reads; validation explicitly adds one site read. No polling,
automatic retries, or extra read endpoints are introduced.

The native timeseries chart has no timezone formatting option. OpenAnalytics
aligns the returned buckets to the configured timezone, and the page formats
range/freshness text in that timezone, but native chart tick/tooltips use the
administrator's browser timezone. The UI discloses this and uses a neutral axis
label; timestamps are never shifted to fake timezone formatting.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"prepublishOnly": "npm run check"
},
"devDependencies": {
"@emdash-cms/blocks": "1.0.1",
"@types/node": "24.10.1",
"emdash": "1.0.1",
"oxfmt": "0.59.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ assert.equal(plugin.admin.settingsSchema.privateReadKey.type, "secret");
assert(plugin.capabilities.includes("hooks.page-fragments:register"));
assert.equal(typeof plugin.hooks["page:fragments"].handler, "function");
assert.equal(typeof plugin.routes["validate-connection"].handler, "function");
assert.equal(plugin.routes["validate-connection"].permission, "plugins:manage");
assert.equal(typeof plugin.routes.admin.handler, "function");
assert.equal(plugin.routes.admin.permission, "plugins:manage");
assert.deepEqual(plugin.routes.admin.methods, ["POST"]);
assert(plugin.admin.pages.some((page) => page.path === "/analytics"));
assert.equal(plugin.admin.entry, undefined);

// Prefix checks and examples are expected; a concrete private credential is not.
for (const file of await readdir(resolve(root, "dist"))) {
Expand Down
Loading
Loading