فهرست منبع

fix(session-telemetry-otel): keep gzip on the fetch transport

The previous commit moved the OTLP exporter to the SDK's `fetch` delegate
and refused `exporter.compression` at load, on the belief that nothing
shipped enabled it. `packages/bundle/base/cordis.patch.yml` does, so the
refusal broke every test that boots the shipped bundle — the snapshot,
e2e, and Windows observational jobs all failed on that one load error.

Dropping gzip was the wrong trade anyway: a realistic OTLP batch measures
6.4x smaller with it, so trading it for proxy support would have charged
every deployment to fix one. The `fetch` transport has no compression
hook, but serialization is the seam before the body reaches it — the
plugin now gzips there and declares `Content-Encoding` itself.

`keepAlive` and `httpAgentOptions` have no such seam, since they
configure a connection pool `fetch` does not expose, so those two stay
refused at load rather than accepted and ignored. `compression` is typed
as the two values this package can actually apply rather than the SDK's
wider enum, and a third value fails loud at load.
Yichen Jiang 1 ماه پیش
والد
کامیت
b518286735

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
-2026-08-27-outbound-proxy-policy.md: 88bfe542d322d2caee5f5e220ed211e0169fa614
-2026-08-27-outbound-proxy-policy.zh.md: f5afee9de40e2032cfd4eda3bc9bfb1c627e71aa
+2026-08-27-outbound-proxy-policy.md: 9c58dfd00d9b6c0b4438ad3dd4da58d8706f718f
+2026-08-27-outbound-proxy-policy.zh.md: 1aabc716c1bdf348229b260c4d8580fb4edc8637

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md

@@ -50,7 +50,9 @@ This accepts a documented seam. Such a context matches bypass entries by Node's
 
 The exporter now composes `OTLPExporterBase` with `createLegacyOtlpBrowserExportDelegate` — a published entry point of the same SDK package, and the one that posts through `fetch`. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes.
 
-Switching the exporter to `fetch` costs `compression`: gzip belongs to the SDK's Node transport, and a realistic OTLP batch measured 6.4x smaller with it. Nothing shipped enabled it, and telemetry that ignores the proxy simply fails inside a corporate network, so routing wins. What the exporter would silently ignore, the plugin now refuses at load — `exporter.compression`, `exporter.keepAlive`, and `exporter.httpAgentOptions` throw with the reason, so no deployment pays the difference without seeing it. In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4.
+The `fetch` transport has no compression, and the shipped `base` bundle enables gzip — a realistic OTLP batch measures 6.4x smaller with it. Dropping it to gain proxy support would have traded one deployment's problem for every deployment's, and the first attempt did exactly that: it refused `exporter.compression` at load, which broke every test that boots the shipped bundle. This package gzips at the serializer instead, the one seam before the body reaches the transport, and declares `Content-Encoding` itself. `keepAlive` and `httpAgentOptions` have no such seam — they configure a connection pool `fetch` does not expose — so those two are refused at load rather than accepted and ignored.
+
+In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4.
 
 **Every call site carries an egress test, because reading the code was not enough.** `egress.spec.ts` in each owning package drives that site's real code path at an unresolvable `.invalid` host through a fake proxy and asserts the proxy saw the request. Nine of them cover the search backends, pi-ai discovery, MCP over HTTP, telemetry, E2B, a spawned child Node, and a worker thread. The gate below cannot see inside a dependency; these can, and they are what turns "an SDK changed its transport" from a silent regression into a failing test.
 

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md

@@ -50,7 +50,9 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
 
 导出器改为用 `OTLPExporterBase` 组合 `createLegacyOtlpBrowserExportDelegate`——同一个 SDK 包的公开入口,也是通过 `fetch` 投递的那一个。E2B 则接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。
 
-把导出器换到 `fetch` 的代价是 `compression`:gzip 属于该 SDK 的 Node 传输,实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。目前没有任何随附配置启用它,而在企业代理网络里,不遵循代理的遥测干脆发不出去,因此路由优先。导出器本会静默忽略的选项,现在由插件在加载期拒绝——`exporter.compression`、`exporter.keepAlive` 与 `exporter.httpAgentOptions` 会带着原因抛错,任何部署都不会在看不见的情况下承担这个差价。换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。
+`fetch` 传输没有压缩能力,而随附的 `base` bundle 启用了 gzip——实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。为了拿到代理支持而丢掉它,等于用每个部署的代价去换一个部署的问题;第一版正是这么做的:它在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败。改为由本包在 serializer 处 gzip——那是请求体抵达传输前的唯一接缝——并自行声明 `Content-Encoding`。`keepAlive` 与 `httpAgentOptions` 没有这样的接缝,它们配置的是 `fetch` 不暴露的连接池,因此这两个仍在加载期拒绝,而不是被接受后忽略。
+
+换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。
 
 **每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。
 

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 6fdd34855e4456237464bfb225b65b55b86fbf2e
-config-catalog.zh.md: 9ca1423556ecee7f839c0ef66b5628fd036b6f94
+config-catalog.md: e451d76631252a1457bfc225784adc096c9ad548
+config-catalog.zh.md: b54d57d3a63693d8f6e2e83081c7116b6c5ced5c

+ 13 - 4
docs/config-catalog.md

@@ -1971,13 +1971,16 @@ export interface Config {
    * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
    * one field this package requires and validates itself.
    *
-   * The transport is the SDK's `fetch` one, so the three options that exist
-   * only for its `node:http` transport — `compression`, `keepAlive`, and
-   * `httpAgentOptions` — are refused at load rather than ignored.
+   * The transport is the SDK's `fetch` one, so `keepAlive` and
+   * `httpAgentOptions` — which configure its `node:http` transport — are
+   * refused at load rather than ignored. `compression` is honored by this
+   * package instead of by that transport.
    */
   exporter?: OTLPExporterConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
+    /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */
+    compression?: SupportedCompression
   }
   /**
    * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
@@ -1994,11 +1997,17 @@ export enum SessionTelemetryMode {
   FEEDBACK_ONLY = 'FEEDBACK_ONLY',
   DISABLED = 'DISABLED',
 }
+
+/**
+ * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`,
+ * which also spells `deflate`: the `fetch` transport offers no seam to apply that one.
+ */
+export type SupportedCompression = 'gzip' | 'none'
 ```
 
 Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterConfigBase` (`@opentelemetry/otlp-exporter-base`)
 
-Source: [`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts)
+Source: [`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 13 - 4
docs/config-catalog.zh.md

@@ -1973,13 +1973,16 @@ export interface Config {
    * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
    * one field this package requires and validates itself.
    *
-   * The transport is the SDK's `fetch` one, so the three options that exist
-   * only for its `node:http` transport — `compression`, `keepAlive`, and
-   * `httpAgentOptions` — are refused at load rather than ignored.
+   * The transport is the SDK's `fetch` one, so `keepAlive` and
+   * `httpAgentOptions` — which configure its `node:http` transport — are
+   * refused at load rather than ignored. `compression` is honored by this
+   * package instead of by that transport.
    */
   exporter?: OTLPExporterConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
+    /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */
+    compression?: SupportedCompression
   }
   /**
    * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
@@ -1996,11 +1999,17 @@ export enum SessionTelemetryMode {
   FEEDBACK_ONLY = 'FEEDBACK_ONLY',
   DISABLED = 'DISABLED',
 }
+
+/**
+ * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`,
+ * which also spells `deflate`: the `fetch` transport offers no seam to apply that one.
+ */
+export type SupportedCompression = 'gzip' | 'none'
 ```
 
 依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterConfigBase`(`@opentelemetry/otlp-exporter-base`)
 
-来源:[`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts)
+来源:[`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 63 - 10
packages/session/session-telemetry-otel/src/index.ts

@@ -13,6 +13,7 @@
  */
 
 import { createRequire } from 'node:module'
+import { gzipSync } from 'node:zlib'
 import z from '@deepseek-ai/schemastery'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-command-feedback'
@@ -34,6 +35,7 @@ import {
 import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'
 import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'
 import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
+import type { ISerializer } from '@opentelemetry/otlp-transformer'
 import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'
 import type { ReadableLogRecord } from '@opentelemetry/sdk-logs'
 import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs'
@@ -100,13 +102,16 @@ export interface Config {
    * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
    * one field this package requires and validates itself.
    *
-   * The transport is the SDK's `fetch` one, so the three options that exist
-   * only for its `node:http` transport — `compression`, `keepAlive`, and
-   * `httpAgentOptions` — are refused at load rather than ignored.
+   * The transport is the SDK's `fetch` one, so `keepAlive` and
+   * `httpAgentOptions` — which configure its `node:http` transport — are
+   * refused at load rather than ignored. `compression` is honored by this
+   * package instead of by that transport.
    */
   exporter?: OTLPExporterConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
+    /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */
+    compression?: SupportedCompression
   }
   /**
    * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
@@ -142,7 +147,45 @@ const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
  * Exporter options the SDK defines only for its `node:http` transport. They reach the `fetch`
  * transport this package uses, which silently ignores every one of them.
  */
-const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['compression', 'keepAlive', 'httpAgentOptions'] as const
+const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['keepAlive', 'httpAgentOptions'] as const
+
+/** The one encoding {@link gzipSerializer} applies, spelled as the OTLP `Content-Encoding` spells it. */
+const GZIP = 'gzip'
+
+/**
+ * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`,
+ * which also spells `deflate`: the `fetch` transport offers no seam to apply that one.
+ */
+export type SupportedCompression = 'gzip' | 'none'
+
+/** {@link SupportedCompression} as values, for the load-time check on a configuration typed `any`. */
+const SUPPORTED_COMPRESSION: readonly string[] = [GZIP, 'none'] satisfies SupportedCompression[]
+
+/**
+ * Wrap a serializer so every batch it produces is gzipped.
+ *
+ * The SDK compresses in its `node:http` transport, which the `fetch` transport this package uses
+ * does not have; serialization is the one seam before the body reaches that transport. The shipped
+ * profile enables gzip, and a realistic batch measures over six times smaller with it, so dropping
+ * compression to gain proxy support would trade one deployment's problem for every deployment's.
+ *
+ * `gzipSync` runs on the export path, but a batch is bounded by `maxExportBatchSize` and exports are
+ * already off the request path — the batch processor schedules them.
+ *
+ * @param serializer - the SDK serializer producing the uncompressed request body.
+ * @returns a serializer producing the gzipped body, deserializing responses unchanged.
+ */
+function gzipSerializer<Request, Response>(serializer: ISerializer<Request, Response>): ISerializer<Request, Response> {
+  return {
+    ...serializer,
+    serializeRequest: (request) => {
+      const serialized = serializer.serializeRequest(request)
+      // The SDK returns nothing for a batch it could not serialize. Gzipping that would post an
+      // empty frame the collector accepts as a valid, empty export.
+      return serialized === undefined ? undefined : gzipSync(serialized)
+    },
+  }
+}
 
 /** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */
 const SEVERITY: Record<SessionTelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
@@ -195,12 +238,19 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
     if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
       throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`)
     }
-    // Options that exist only for the SDK's `node:http` transport, which this package no longer
-    // uses. The exporter would accept and ignore each one, so a deployment that asked for gzip
-    // would quietly send uncompressed batches; refusing at load is what makes the change visible.
+    // `keepAlive` and `httpAgentOptions` configure the SDK's `node:http` transport, which this
+    // package does not use — the `fetch` transport is what reaches a configured proxy. The exporter
+    // would accept and ignore them, so a deployment would believe it had tuned a connection it had
+    // not. `compression` is the third such option and is honored instead of refused, below.
     const nodeOnly = NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS.filter(name => name in exporter)
     if (nodeOnly.length > 0) {
-      throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch so a configured proxy carries it, and the node:http transport those options belong to would need an http.Agent this package no longer builds`)
+      throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch, whose connections Node owns; ${nodeOnly.length === 1 ? 'that option belongs' : 'those options belong'} to the node:http transport this package no longer builds an agent for`)
+    }
+    // Compared as strings because that is what arrives: the schema validates this object as `any`,
+    // so a cordis.yml may name any algorithm, including one the SDK's enum does not spell.
+    const compression: string = exporter.compression ?? 'none'
+    if (!SUPPORTED_COMPRESSION.includes(compression)) {
+      throw new Error(`session-telemetry-otel: exporter.compression must be one of ${SUPPORTED_COMPRESSION.map(value => JSON.stringify(value)).join(', ')}, got ${JSON.stringify(compression)}`)
     }
     // The one processor field checked beyond the SDK's own validation: the
     // SDK accepts a non-positive batch size, but its shutdown drain then
@@ -251,9 +301,12 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
             // oxlint-disable-next-line typescript/no-deprecated -- the SDK exports its replacement from no public subpath at 0.220.
             createLegacyOtlpBrowserExportDelegate(
               exporter,
-              JsonLogsSerializer,
+              compression === GZIP ? gzipSerializer(JsonLogsSerializer) : JsonLogsSerializer,
               'v1/logs',
-              { 'Content-Type': 'application/json' },
+              {
+                'Content-Type': 'application/json',
+                ...compression === GZIP ? { 'Content-Encoding': GZIP } : {},
+              },
             ),
           ),
         }),

+ 80 - 5
packages/session/session-telemetry-otel/tests/otel.spec.ts

@@ -12,6 +12,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { gunzipSync } from 'node:zlib'
+import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
 import { Context } from '@deepseek-ai/cordis'
 import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
@@ -255,17 +256,91 @@ describe('OpenTelemetrySessionBackend wire', () => {
     expect(types).toContain('turn/start')
   })
 
+  it('gzips the batch when the shipped profile asks for it', async () => {
+    const { url, captures } = await mockCollector()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    // The shipped `base` bundle sets this, and a realistic batch is over six times smaller with it.
+    // The SDK compresses in its `node:http` transport, which the `fetch` transport used here does
+    // not have, so this package gzips at the serializer and declares the encoding itself.
+    const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
+      mode: SessionTelemetryMode.FULL,
+      exporter: { url, compression: 'gzip' },
+    })
+    const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
+    session.append('turn/start', { turn: 1 })
+    await fiber.dispose()
+
+    expect(captures.length).toBeGreaterThan(0)
+    expect(captures[0]!.headers['content-encoding']).toBe('gzip')
+    // The collector gunzips the body it received, so the header is not merely asserted alongside a
+    // plaintext payload the encoding would have misdescribed.
+    const types = allRecords(captures).flatMap(({ record }) =>
+      record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? [])
+    expect(types).toContain('turn/start')
+  })
+
+  it('sends the batch uncompressed when no compression is configured', async () => {
+    const { url, captures } = await mockCollector()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url } })
+    ctx.sessions.create(SessionId('plain'), { meta: {} }).append('turn/start', { turn: 1 })
+    await fiber.dispose()
+
+    expect(captures.length).toBeGreaterThan(0)
+    expect(captures[0]!.headers['content-encoding']).toBeUndefined()
+  })
+
+  it('sends nothing when the SDK cannot serialize the batch, rather than an empty gzip frame', async () => {
+    const { url, captures } = await mockCollector()
+    const serialize = vi.spyOn(JsonLogsSerializer, 'serializeRequest').mockReturnValue(undefined)
+    try {
+      const ctx = new Context()
+      await ctx.plugin(SessionStore)
+      const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
+        mode: SessionTelemetryMode.FULL,
+        exporter: { url, compression: 'gzip' },
+      })
+      ctx.sessions.create(SessionId('unserializable'), { meta: {} }).append('turn/start', { turn: 1 })
+      await fiber.dispose()
+      expect(serialize).toHaveBeenCalled()
+      expect(captures).toEqual([])
+    } finally {
+      serialize.mockRestore()
+    }
+  })
+
+  it('names every node:http option a configuration set, not just the first', async () => {
+    const { url } = await mockCollector()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await expect(ctx.plugin(OpenTelemetrySessionBackend, {
+      mode: SessionTelemetryMode.FULL,
+      exporter: { url, keepAlive: true, httpAgentOptions: {} },
+    } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive, exporter\.httpAgentOptions not supported/)
+  })
+
   it('refuses an exporter option that belongs to the node:http transport', async () => {
     const { url } = await mockCollector()
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    // Telemetry goes through `fetch` so a configured proxy carries it, and that transport ignores
-    // `compression`. Accepting the option would send uncompressed batches while the configuration
-    // said gzip; the deployment has to see the trade rather than pay it silently.
+    // `keepAlive` tunes a `node:http` connection pool this package no longer builds. Accepting it
+    // would let a deployment believe it had tuned a connection that does not exist.
     await expect(ctx.plugin(OpenTelemetrySessionBackend, {
       mode: SessionTelemetryMode.FULL,
-      exporter: { url, compression: 'gzip' },
-    } as unknown as Config)).rejects.toThrow(/exporter\.compression not supported/)
+      exporter: { url, keepAlive: true },
+    } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive not supported/)
+  })
+
+  it('refuses a compression algorithm it cannot apply', async () => {
+    const { url } = await mockCollector()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await expect(ctx.plugin(OpenTelemetrySessionBackend, {
+      mode: SessionTelemetryMode.FULL,
+      exporter: { url, compression: 'deflate' },
+    } as unknown as Config)).rejects.toThrow(/exporter\.compression must be one of "gzip", "none"/)
   })
 
   it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => {