All files / src/core/services AnalyticsService.ts

100% Statements 18/18
91.66% Branches 11/12
100% Functions 6/6
100% Lines 16/16

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 15128x 28x 28x                           28x                       28x             28x               36x                                                           28x                           36x 23x     13x                                             2x                       1x                         15x 2x     13x              
import { AnalyticsBinding } from '@/core/bindings/AnalyticsBinding'
import { Text } from '@bayudwiyansatria/core'
import { lazySettings } from '@/utils/lazySettings'
 
import type { RequestMetric, TelemetrySink } from '@bayudwiyansatria/core'
import type { AnalyticsSettings } from '@/types/AnalyticsSettings'
import type { CloudflareEnv } from '@/types/CloudflareEnv'
 
/**
 * Analytics Engine binding.
 *
 * Every setting it needs — the dataset's binding name — arrives resolved from
 * the configuration layer, so nothing about it is decided here. The binding
 * itself is read from `env` per call, which is what makes one module-scope
 * instance safe to share.
 */
const dataset = new AnalyticsBinding()
 
/**
 * Whether telemetry is switched on for this deployment.
 *
 * A configuration decision, not a runtime one — override `analytics.enabled`
 * to silence metrics without touching a call site.
 *
 * Read through a memoised getter rather than settled here, for the same reason
 * the accessors defer their settings: this runs while a consumer's imports are
 * still being hoisted, before `configure()` has had a chance to run.
 */
const settings = lazySettings<AnalyticsSettings>('analytics')
 
/**
 * Whether telemetry should be written at all.
 *
 * @returns `true` unless `analytics.enabled` is off.
 */
const enabled = () => settings().enabled
 
/**
 * Whether a request that ended at this status is worth a data point.
 *
 * @param status The response status the request ended at.
 * @returns `true` when `status` reaches `analytics.minRequestStatus`.
 */
const recordable = (status: number) => status >= settings().minRequestStatus
 
/**
 * One request measurement — the kernel's `TelemetrySink` owns the shape.
 * Re-exported here under the name callers have always imported.
 */
export type { RequestMetric } from '@bayudwiyansatria/core'
 
/**
 * Telemetry capability, backed by Analytics Engine.
 *
 * Records **measurements** — durations, counts, statuses — as data points to
 * aggregate with SQL: request rate, latency percentiles, error rate, and
 * whatever the domain wants counted.
 *
 * @remarks
 * Deliberately separate from `Logger`. Analytics
 * Engine samples at volume, which is right for aggregates and wrong for
 * individual lines, so logs go to the observability logstream and only metrics
 * come here.
 *
 * Writes are best-effort and never throw: telemetry must not be able to fail
 * the request it is describing.
 *
 * @class
 *
 * @author Bayu Dwiyan Satria
 * @version 1.0.0
 * @since 1.0.0
 */
export class AnalyticsService<E extends CloudflareEnv = CloudflareEnv> implements TelemetrySink<E> {
  /**
   * Records one request: its duration and a unit count, grouped by route with
   * method, status, colo, and country as filter dimensions.
   *
   * @remarks
   * Returns `false` without writing when the status is below
   * `analytics.minRequestStatus`, which is `500` unless a deployment widens it.
   *
   * @param env The Worker environment.
   * @param metric The request measurement.
   * @returns `true` when the data point was accepted.
   */
  public request(env: E, metric: RequestMetric): boolean {
    if (!recordable(metric.status)) {
      return false
    }
 
    return this.write(
      env,
      metric.route,
      [metric.method, String(metric.status), metric.colo || '', metric.country || ''],
      [metric.durationMs, 1]
    )
  }
 
  /**
   * Records a domain metric.
   *
   * @example
   * ```ts
   * analytics.event(env, 'article.create', [String(id)], [body.length])
   * ```
   *
   * @param env The Worker environment.
   * @param name The metric name — the sampling key it is grouped by.
   * @param dimensions String dimensions to filter and group by.
   * @param measures Numeric measurements to aggregate.
   * @returns `true` when the data point was accepted.
   */
  public event(env: E, name: string, dimensions: string[] = [], measures: number[] = [1]): boolean {
    return this.write(env, name, dimensions, measures)
  }
 
  /**
   * Reports whether telemetry will actually be recorded.
   *
   * `false` when the dataset is unbound or `analytics.enabled` is off.
   *
   * @param env The Worker environment.
   * @returns `true` when data points are being written.
   */
  public isAvailable(env: E): boolean {
    return enabled() && dataset.isBound(env)
  }
 
  /**
   * Writes one data point, swallowing anything that goes wrong.
   *
   * @param env The Worker environment.
   * @param index The sampling key. Analytics Engine caps this at 96 bytes.
   * @param blobs String dimensions.
   * @param doubles Numeric measurements.
   * @returns `true` when the data point was accepted.
   */
  private write(env: E, index: string, blobs: string[], doubles: number[]): boolean {
    if (!enabled()) {
      return false
    }
 
    return dataset.writeSafe(env, {
      indexes: [Text.truncate(index, 96)],
      blobs,
      doubles
    })
  }
}