Documentation
Documentation/Integrations

Automation SDK

Instrument external Node.js automations and native Playwright jobs with the private SDK.

sdktypescriptexternal runsnative bridgetelemetry
View Markdown
On this page

The private TypeScript automation SDK adds structured BernOps telemetry to external Node.js automations and BernOps-managed Playwright jobs. This guide covers the supported 0.1.0-internal.3 artifact. The package is UNLICENSED and is not published to a public registry. External consumers install the exact retained release bundle; deployed native jobs use the exact artifact provisioned by their BernOps worker operator.

Prerequisites and installation

  • Use Node.js 22 or 24 for external automations and local native-job authoring. Node.js 24 is recommended.
  • For an external automation or local type-checking, ask your BernOps release owner for the matching .tgz, .tgz.sha256, and .manifest.json files. Keep the three files together.
  • Import only from @bernops/automation. ESM, CommonJS, and TypeScript consumers are supported; deep imports are not.

Verify the checksum before installing the artifact into an external automation or local authoring project:

export SDK_ARTIFACT_DIR=/absolute/path/to/retained-candidate
export SDK_TARBALL="$SDK_ARTIFACT_DIR/bernops-automation-0.1.0-internal.3.tgz"
 
cd "$SDK_ARTIFACT_DIR"
sha256sum --check "$(basename "$SDK_TARBALL").sha256"
 
cd /absolute/path/to/your-automation
npm install --offline --ignore-scripts --no-audit --no-fund \
  --registry=https://registry.invalid/ "$SDK_TARBALL"
npm ls @bernops/automation

The SHA-256 sidecar must come from the same trusted release as the manifest. A release owner with the matching BernOps source revision can run the complete manifest, package-content, declaration, and clean-consumer gate:

cd /absolute/path/to/bernops/sdk/typescript
npm run artifact:verify -- --manifest \
  "$SDK_ARTIFACT_DIR/bernops-automation-0.1.0-internal.3.manifest.json"
Use one exact artifact

For external and local projects, do not replace the tarball with a workspace link, source-directory install, mutable alias, deep import, or public registry fallback. A deployed native job does not use your local node_modules; its worker provisions the package. If either artifact version differs from this guide, use the matching documentation and compatibility policy instead of assuming compatibility.

Choose a transport

Where the automation runsTransportCredentialLifecycle owner
CI, scheduler, service, or script outside a BernOps workerexternalJob-scoped ingest-runner API keySDK
Playwright job dispatched by a BernOps workernativeWorker-injected loopback token; no API keyWorker

Select the transport explicitly with BernOps.fromEnv({ transport: 'external' }) or BernOps.fromEnv({ transport: 'native' }). Use auto only when the deployment guarantees exactly one complete configuration. Selection fails closed and never falls back after authentication, TLS, identity, or capability errors.

External quick start

Create an external job and an ingest-runner key, then store the plaintext key in the process secret manager. The minimal writer scopes are runs:write, runs:finish, logs:write, steps:write, metrics:write, progress:write, and attachments:write. Read and admin scopes are not required by the SDK writer.

export BERNOPS_BASE_URL=https://bernops.example
export BERNOPS_API_KEY='job-scoped-secret'
export BERNOPS_EXTERNAL_JOB_ID='external-job-uuid'
export AUTOMATION_RUN_ID='scheduler-attempt-123'

Set AUTOMATION_RUN_ID from a durable scheduler, CI-system, or business-attempt ID. Reuse that value when the same attempt is retried; do not generate a new ID on each process start.

import { BernOps, type DeliveryReport } from '@bernops/automation';
 
function requiredEnv(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}
 
function deliveryIsDegraded(report: DeliveryReport): boolean {
  return report.pending > 0 || report.failed > 0 ||
    report.dropped > 0 || report.timedOut;
}
 
const client = BernOps.fromEnv({
  transport: 'external',
  redaction: { secretEnvironmentVariables: ['CUSTOM_SERVICE_TOKEN'] },
  onDiagnostic(diagnostic) {
    // The callback receives bounded, already-redacted SDK diagnostics.
    console.warn(`[bernops:${diagnostic.code}] ${diagnostic.message}`);
  },
});
 
try {
  await client.capabilities();
  await client.run(
    {
      externalRunId: requiredEnv('AUTOMATION_RUN_ID'),
      name: 'nightly import',
      service: 'catalog',
      environment: 'production',
    },
    async (run) => {
      const admission = run.log.info('import started');
      if (!admission.accepted) {
        console.warn(`BernOps event was not queued: ${admission.reason}`);
      }
 
      await run.step('import records', async () => {
        run.metric.count('records.imported', 250, { unit: 'items' });
        run.progress({ current: 250, total: 500, stage: 'importing' });
      });
 
      const report = await run.flush();
      if (deliveryIsDegraded(report)) console.warn('BernOps delivery degraded', report);
    }
  );
} finally {
  const report = await client.shutdown();
  if (deliveryIsDegraded(report)) console.warn('BernOps shutdown degraded', report);
}

BERNOPS_EXTERNAL_JOB_ID identifies the saved BernOps job. externalRunId identifies one durable attempt of that job. If externalRunId is omitted, the generated value is replay-safe only inside the current process.

Prefer client.run() because it closes open steps, flushes telemetry, completes or fails an external run, and preserves the callback's return value or exact thrown error. Use startRun() only when an external caller must own one run across several functions; await complete() or fail() before shutdown.

Native Playwright quick start

Do not add a tarball or package installation command to a saved BernOps Playwright job. With the approved private bootstrap installed, run bernops setup and choose 3 (automation worker) or 4 (both); the menu defaults to auth-only. Unified worker setup obtains a signed immutable release with the exact private SDK already bundled, checks real SDK/browser readiness, and requires separate workspace approval for native_bridge_v1 plus a confirmed worker connection. No source checkout build, public SDK install, or artifact mount is part of that flow. A local package installation is useful for authoring and type-checking only; it does not provision the deployed worker. Existing legacy workers are not silently upgraded. Ask the operator to check release availability and approved capabilities if the package import or native capability is unavailable.

The BernOps worker injects the bridge configuration for each execution. Do not set or copy AB_LOG_URL, BERNOPS_BRIDGE_TOKEN, BERNOPS_RUN_ID, or BERNOPS_PROJECT_ID, and do not add a BERNOPS_API_KEY to the job.

import { test, expect } from '@playwright/test';
import { BernOps } from '@bernops/automation';
 
test('checkout', async ({ page }) => {
  const client = BernOps.fromEnv({
    transport: 'native',
    onDiagnostic(diagnostic) {
      console.warn(`[bernops:${diagnostic.code}] ${diagnostic.message}`);
    },
  });
 
  try {
    const capabilities = await client.capabilities();
    expect(capabilities.lifecycleOwner).toBe('worker');
 
    await client.run({ name: 'checkout' }, async (run) => {
      run.log.info('opening checkout');
      await run.step('submit order', async () => {
        await page.goto('/checkout');
      });
    });
  } finally {
    await client.shutdown();
  }
});

The worker and Playwright own start, heartbeat, and terminal status. run.complete() and run.fail() exist on the shared type but reject in native mode with BernOpsCapabilityError and code === 'lifecycle_owned_by_worker'. Call capabilities() before optional behavior: native persistent attachments are available only when persistentAttachments is true, and the returned size limits are authoritative.

Telemetry, progress, and attachments

APIBehavior
run.log.debug/info/warn/errorCaptures a structured log and returns queue admission immediately.
run.step / run.startStepCreates callback-managed or manually ended steps. Pass a real parent step handle for detached work.
run.metric.gauge/count/rate/durationCaptures low-cardinality metrics. Durations are milliseconds.
run.progressReports count, percent, or indeterminate advisory progress.
run.attachHashes, uploads, and persistently acknowledges one replayable attachment. Always await it.
run.flushWaits for accepted work through the current watermark and leaves the run writable.

Progress may coalesce while pending, so sequence gaps are valid. It never determines pass/fail and BernOps does not fabricate 100 percent for a running execution. Keep metric names and tags low-cardinality: do not use email addresses, user IDs, request IDs, timestamps, full URLs, or free-form values as names or tags.

An attachment must use exactly one replayable source: path, Uint8Array or Blob data, or an open factory that returns a fresh unread stream for every delivery attempt.

// `run` is the handle supplied to a client.run() callback.
const attachment = await run.attach({
  path: '/tmp/import-summary.json',
  fileName: 'import-summary.json',
  contentType: 'application/json',
});
 
console.log(attachment.attachmentId, attachment.sizeBytes, attachment.duplicate);

Supported content types are PNG, JPEG, WebP, plain text, JSON, CSV, and PDF. Do not change path or stream bytes until attach() settles. The acknowledgement represents a persistent metadata and storage commit, not queue admission.

Delivery, errors, and security

Log, metric, and progress methods return an EnqueueResult; accepted: true means only that the in-memory queue accepted the event. Network delivery is observed at flush(), external terminal methods, and shutdown() through a DeliveryReport containing delivered, duplicate, dropped, pending, failed, timedOut, and droppedByReason.

The default failurePolicy: 'diagnostic' preserves the automation result when telemetry degrades and reports the problem through onDiagnostic and delivery reports. Choose failurePolicy: 'strict' only when successful business work should fail because required telemetry could not be delivered. Under strict policy, explicit delivery boundaries can throw BernOpsDeliveryError; the SDK still never replaces an error originally thrown by your callback.

Branch on public error classes, code, retryable, and class-specific safe fields rather than matching message text:

ErrorTypical action
BernOpsConfigurationErrorFix incomplete or ambiguous transport, URL, credential, delivery, or redaction configuration.
BernOpsAuthenticationErrorRotate or correct the key, or investigate the current worker assignment. Do not retry unchanged credentials.
BernOpsValidationErrorCorrect unsupported or invalid telemetry, progress, metric, or attachment input.
BernOpsCapabilityErrorBranch on negotiated capabilities and lifecycle ownership.
BernOpsStateErrorStop conflicting terminal or step calls and check for stale state.
BernOpsTransportErrorInspect status, requestId, retryAfterMs, and retryable.
BernOpsDeliveryErrorInspect the immutable report returned by a strict delivery boundary.
BernOpsFinalizedRunErrorTreat the durable attempt as already terminal or intentionally use a new business-attempt ID.
  • External endpoints require HTTPS. Plain HTTP is accepted only for literal 127.0.0.1 or [::1] test URLs configured explicitly with allowInsecureLoopback: true; URL credentials and redirects are rejected.
  • Redaction is defense in depth. Never capture authorization headers, cookies, tokens, personal data, card data, or unrestricted request and response bodies.
  • Attachment bytes are not redacted. Scrub files before upload, avoid unnecessary screenshots, and apply suitable retention and access controls.
  • Queues and retries are bounded and in memory; they do not survive SIGKILL, OOM, host loss, or power loss.
  • Importing the package installs no process signal handlers. The host owns signal policy, but every normal path must await a bounded client.shutdown() in finally.

Troubleshooting

SymptomCheck
Transport is incomplete or ambiguousSelect external or native explicitly and remove variables belonging to the other transport.
External URL is rejectedUse HTTPS. For a local test, use an explicit constructor with literal loopback and allowInsecureLoopback: true.
Authentication failsVerify job/project binding and key rotation. Do not retry an unchanged rejected credential.
Native identity or capability failsCreate a new client inside the current worker assignment; never reuse copied bridge values.
A durable external run is finalizedReuse it only to observe the prior terminal result, or choose a new ID for a genuinely new business attempt.
Delivery is pending, dropped, failed, or timed outInspect onDiagnostic, the report, and droppedByReason; fix transport and timeout causes before increasing queue bounds.
Attachment changes or cannot be reopenedKeep path/byte sources stable and return a new identical unread stream from each factory call.
The process does not exitAwait attachments, flush or terminal boundaries, and client.shutdown(); then clean up host-owned timers and listeners.

Do not hide permanent failures with unbounded retries, broader scopes, disabled TLS, redirect following, raw response-body logging, or larger queues.

The installed artifact includes its matching README.md, CHANGELOG.md, COMPATIBILITY.md, UPGRADING.md, and TypeScript declarations. Use those versioned files and your IDE's package-root types for exact signatures; do not copy an API signature from a different SDK revision.