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
16 changes: 13 additions & 3 deletions apps/client-web/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

import {
configStore,
initializeTelemetry,
shutdownTelemetry,
flushTelemetry,
ExtensionRegistryOriginResolver,
DEFAULT_EXTENSION_REGISTRY_ORIGIN,
localStorageAdapter,
Expand Down Expand Up @@ -205,9 +208,10 @@ export function getExtensionSetupContribution(name: string): ExtensionSetupContr
}

/** Replace the browser-owned lease runtime after a validated Settings cutover. */
export function adoptWebPeerRuntime(runtime: WebPeerRuntime): void {
export async function adoptWebPeerRuntime(runtime: WebPeerRuntime): Promise<void> {
webPeerRuntime?.stop()
webPeerRuntime = runtime
await initializeTelemetry(configStore.metaConfig, WEB_PEER_IDENTITY.applicationVersion)
JobManager.startWorker()
}

Expand All @@ -216,6 +220,7 @@ export async function stopWebPeerRuntime(): Promise<void> {
webPeerRuntime = null
currentWebPeer.value = null
await JobManager.stopWorker()
await shutdownTelemetry()
}

/** Start the lease after Settings has mounted and loaded recovery configuration. */
Expand All @@ -226,7 +231,7 @@ export async function startConfiguredWebPeerRuntime(): Promise<void> {
const peer = await candidate.register()
await configStore.loadPeerConfig()
await candidate.start()
adoptWebPeerRuntime(candidate)
await adoptWebPeerRuntime(candidate)
currentWebPeer.value = { id: peer.id, name: peer.name }
} catch (error) {
candidate.stop()
Expand Down Expand Up @@ -335,7 +340,7 @@ export async function initializeCore(options: { loadPeerConfig?: boolean } = {})
const peer = await candidate.register()
await configStore.loadPeerConfig()
await candidate.start()
adoptWebPeerRuntime(candidate)
await adoptWebPeerRuntime(candidate)
currentWebPeer.value = { id: peer.id, name: peer.name }
} catch (error) {
candidate.stop()
Expand All @@ -353,3 +358,8 @@ export async function initializeCore(options: { loadPeerConfig?: boolean } = {})
export async function shutdownCore(): Promise<void> {
await stopWebPeerRuntime()
}

// Page termination is best effort; normal connection shutdown awaits bounded SDK drain.
window.addEventListener('pagehide', () => {
void flushTelemetry()
})
9 changes: 7 additions & 2 deletions apps/client-web/src/locales/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@
"resetConfirmMessage": "Are you sure you want to reset all settings to default? This action cannot be undone.",
"exportConfig": "Export",
"importConfig": "Import",
"importError": "Unable to restore configuration. Check the file and deployment connection."
"importError": "Unable to restore configuration. Check the file and deployment connection.",
"telemetryEnabled": "Enable telemetry for this browser Peer",
"telemetryHelp": "Takes effect after saving the connection. Requires deployment observability endpoints; existing Job logs remain available.",
"telemetryRelay": "Peer telemetry relay URL (optional)",
"telemetryRelayHelp": "Use only a trusted deployment Peer relay. This connection’s short-lived Peer identity authenticates requests; leave empty to use deployment public observability endpoints."
},
"source": {
"deleteConfirmTitle": "Delete Source",
Expand Down Expand Up @@ -198,7 +202,8 @@
"retry": "Retry",
"notAvailable": "N/A",
"notFound": "Job not found",
"sourceNotFound": "Source not found"
"sourceNotFound": "Source not found",
"openDiagnostics": "Open diagnostics (search by Job ID)"
},
"logs": {
"loading": "Loading logs…",
Expand Down
9 changes: 7 additions & 2 deletions apps/client-web/src/locales/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@
"resetConfirmMessage": "确定要重置所有设置为默认值吗?此操作无法撤销。",
"exportConfig": "导出",
"importConfig": "导入",
"importError": "无法恢复配置,请检查文件及部署连接。"
"importError": "无法恢复配置,请检查文件及部署连接。",
"telemetryEnabled": "启用此浏览器 Peer 的遥测",
"telemetryHelp": "保存连接后生效。需要部署配置观测出口;原有 Job 日志继续保留。",
"telemetryRelay": "Peer 遥测中转地址(可选)",
"telemetryRelayHelp": "仅填写可信部署 Peer 的中转地址。此连接的短期 Peer 身份将用于认证;留空使用部署的公开观测出口。"
},
"source": {
"deleteConfirmTitle": "删除数据源",
Expand Down Expand Up @@ -199,7 +203,8 @@
"retry": "重试",
"notAvailable": "不可用",
"notFound": "未找到任务",
"sourceNotFound": "未找到数据源"
"sourceNotFound": "未找到数据源",
"openDiagnostics": "打开诊断入口(使用 Job ID 查询)"
},
"logs": {
"loading": "正在读取日志…",
Expand Down
10 changes: 9 additions & 1 deletion apps/client-web/src/views/jobs/job/job.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useAsyncState, useIntervalFn } from '@vueuse/core'
import { InkLoading, InkButton, InkField, InkSkeleton } from '@inkcre/ui-web'
import LogsViewer from '@/components/obsrv/LogsViewer/LogsViewer.vue'
import { APIError, Job, JobStatus, Source } from '@inkcre/core'
import { APIError, Job, JobStatus, Source, telemetryDiagnosticsUrl } from '@inkcre/core'
import dayjs from 'dayjs'

const route = useRoute()
Expand Down Expand Up @@ -147,6 +147,14 @@ watch(
<span class="metadata__value">{{ job.id }}</span>
</InkField>

<a
v-if="telemetryDiagnosticsUrl"
:href="telemetryDiagnosticsUrl"
target="_blank"
rel="noopener noreferrer"
>{{ t('job.openDiagnostics') }}</a
>

<InkField :label="t('job.status')">
<div class="flex items-center gap-2" :aria-busy="jobLoading">
<span class="metadata__value" :class="statusColor">
Expand Down
17 changes: 15 additions & 2 deletions apps/client-web/src/views/settings/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const onSave = async () => {
try {
const validatedMeta = MetaConfigSchema.parse(metaFormConfig)
const runtime = await configStore.connectAndSave(validatedMeta, WEB_PEER_IDENTITY)
adoptWebPeerRuntime(runtime)
await adoptWebPeerRuntime(runtime)
void refreshCurrentWebPeer()
Object.assign(metaFormConfig, configStore.metaConfig)
feedback.value = { action: 'save', error: false, message: t('settings.saveSuccess') }
Expand Down Expand Up @@ -128,7 +128,7 @@ const onFileSelected = async (event: Event) => {
try {
const imported = SettingsExportSchema.parse(JSON.parse(await file.text()))
const runtime = await configStore.connectAndSave(imported.metaConfig, WEB_PEER_IDENTITY)
adoptWebPeerRuntime(runtime)
await adoptWebPeerRuntime(runtime)
void refreshCurrentWebPeer()
await setLocale(imported.locale)
Object.assign(metaFormConfig, configStore.metaConfig)
Expand Down Expand Up @@ -169,6 +169,19 @@ const onFileSelected = async (event: Event) => {
:disabled="formBusy"
/>

<label class="flex items-center gap-2">
<input v-model="metaFormConfig.telemetry_enabled" type="checkbox" :disabled="formBusy" />
{{ t('settings.telemetryEnabled') }}
</label>
<p>{{ t('settings.telemetryHelp') }}</p>
<InkInput
v-model="metaFormConfig.telemetry_peer_relay_url"
:label="t('settings.telemetryRelay')"
placeholder="https://..."
:disabled="formBusy"
/>
<p>{{ t('settings.telemetryRelayHelp') }}</p>

<div class="settings-view__actions">
<InkButton
:text="t('settings.saveConfig')"
Expand Down
39 changes: 39 additions & 0 deletions docs/30-unit-tdd/client-runtime-and-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,42 @@ success. `PeerOutcomeUnknown` remains visible and does not trigger reload or aut
- Exact-target routing never degrades to best-effort routing.
- Ambiguous dispatch is never retried automatically.
- Browser bootstrap state is runtime authority; portable build bytes stay environment-neutral.

## 可选遥测

浏览器连接配置的 `telemetry_enabled` 缺省为 false。关闭时不读取共享观测配置、不初始化
新增 SDK、不捕获 Job 提交 carrier,也不发出新增传播头。保存连接后重新初始化;配置文件
导入、导出保留此浏览器本地选择。现有 `job.<id>` PostgreSQL 日志查询始终保留。

开启后只读取 `configs` 中 `inkcre.observability` / `inkcre.observability.v1` 的公共投影。
`deployment_id` 由部署 owner 创建,浏览器不分配部署身份。三个 OTLP/HTTP 完整信号 URL
分别启用标准浏览器 SDK 的 OTLP/HTTP protobuf exporter。连接还可显式指定 `telemetry_peer_relay_url`,
从此 base 的 `/v1/traces`、`/v1/logs`、`/v1/metrics` 经现有短期 Peer JWT 认证导出;
JWT 签名闭包绑定初始化时的连接,旧批次不会借用新连接凭据。此地址不是启用开关,
也没有自动推导或失败回退。配置不可用或初始化失败不会阻止业务启动。配置读取、
exporter 超时和正常关闭排空均有界:配置读取最多等待 10 秒后放弃观测初始化;直发
exporter 为 10 秒,经 relay 为 35 秒,以覆盖服务端最多 32 秒的转发预算和网络余量。
处理器的等待比对应 exporter 多 5 秒。flush/shutdown 的调用方只等待 1.5 秒,这不会
取消已开始的 SDK 导出,也不证明排空成功;页面退出只能 best effort。Job 页面提供可选
`diagnostics_url` 链接,用户以 Job ID 在诊断端查询,不把 Grafana 查询语法写入业务层。

`JobManager.create` 在同一个 PostgREST INSERT 中写入 SDK 注入的提交 carrier。超出
512 UTF-8 bytes 的可选 tracestate 会被省略并计数。执行成功 claim 后创建独立 trace,
通过 SDK Span Link 关联提交;claim、取消和 close 不重写提交列。Job ID 是诊断关联字段,
不是 trace ID。时延指标 `inkcre.operation.duration` 使用与 Core 一致的 `operation` /
`outcome` 标签和 5ms 至 300s 的显式秒分桶,便于跨 Peer 聚合;日志保留 `inkcre.*` 属性。
`job.submitted`、`job.started`、`job.closed` 只从明确的业务边界发出结构化
事件;既有应用日志、异常 message、参数和内容不会桥接到新增 OTLP。

浏览器原生 `await` 不能依赖 StackContextManager 或 ZoneContextManager 自动保留当前
span。本实现显式传递标准 OTel Context:`PeerManager.delegate` 的 context 传给 outbound,
`PeerHTTPOutbound` 在真实请求边界注入;Job submit 的 context 在 await 后仍显式用于
carrier 捕获。执行 trace 自身携带 Job ID 与 Link。扩展 handler 内部、独立 PostgREST 请求
和任意 provider 的原生 async 调用尚不自动继承执行 context;不得将这些独立 span 宣称为
完整执行树。新的调用者可以使用 `JobManager.create` / `PeerManager.delegate` 的可选
Context 参数接续已有 SDK context。

Peer HTTP 是唯一新增 W3C 传播 owner,目标来自已验证的 Peer advertisement。普通
PostgREST 请求只生成本地 client span,不注入传播头,也不冒充远端 server/SQL span。
开启传播的 Peer 请求使用 `redirect: error`,包括同 origin 跳转也不跟随;重定向失败仍为
`PeerOutcomeUnknown` 且不重试,关闭态保留既有 fetch 行为。无全局 fetch patch;外部 provider、Source 和内容读取不会意外携带内部 trace 或 baggage。
33 changes: 33 additions & 0 deletions docs/40-deployment/web-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,36 @@ acceptance is a separate black-box activity.
When an eligible internal PR closes, [`.github/workflows/pages-cleanup.yml`](../../.github/workflows/pages-cleanup.yml)
replaces only that exact preview alias with a closed-page tombstone. Manual cleanup accepts an
explicit positive PR number and applies the same closed internal-PR identity checks.

## 浏览器可选观测出口

部署 owner 先通过 core-py 的部署配置流程创建 `inkcre.observability.v1`,保留稳定的
`deployment_id`,并按需填写 traces、logs、metrics 完整 OTLP/HTTP URL 与诊断入口。
浏览器不会因这些 URL 存在而自动启用;在连接设置中勾选遥测并保存后才接入。
只启用部分信号时其余信号不远端导出;没有有效共享配置时保留本地固定原因诊断,
修复配置后重新保存连接。取消勾选并保存可以停止新采集,PG 历史和 Job 查询不受影响。

浏览器出口必须是部署 owner 已验证的公开受限写入入口,或受控按请求转发入口;
不得把 Grafana/其他 SaaS 的服务端 ingest token 放入浏览器配置、URL、静态环境变量
或前端代码。URL 校验拒绝 userinfo、query 和 fragment,不接受任意自定义认证 headers。
如使用 core-py 的受控中转,在浏览器连接设置显式填写 `telemetry_peer_relay_url`,例如
`https://peer.example/telemetry`。SDK 追加 `/v1/{traces,logs,metrics}`,每批请求用现有
签名 authority 产生此连接的短期 Peer JWT;私密 SaaS headers 仍只在服务器。
浏览器三信号统一使用官方 OTLP/HTTP protobuf exporter 和 `application/x-protobuf`;
core 中转只接受此格式,Trace/Span ID 与 Links 由标准 protobuf 消息保留,不经过通用
JSON 字节转换。该地址需属于可信部署 Peer;填写地址不会启用遥测,失败也不会切回公开出口。
留空时沿用共享公开出口且不附加 Peer JWT。
负责入口的部署 owner 须验证 CORS、写入权限、部署归属、配额和失效行为。浏览器自报的
部署 ID 只是关联属性,不能作为授权依据。客户端不提供常驻 Collector,也不代表某个
SaaS 的真实账号、免费额度或入口已经验收。

Peer HTTP 入口需要在原有认证和 CORS 配置下允许 `traceparent`、`tracestate`;CORS
本身不授予调用权限。开启遥测的 Peer endpoint 必须直接响应,浏览器不跟随重定向,
避免向配置边界外的地址转发内部 context。OTLP 只包含操作名、固定结果、状态码、Job/Peer/部署标识与计时,
不包含请求 URL/query、prompt、工具原文或任意异常 message。受信部署 owner 配置出口,
外部观测服务获取这些元数据;误把私密凭据交给公开浏览器会跨越凭据边界,因此首次出口
验收只使用合成数据,直到公开入口和保留策略获得独立验证。

浏览器正常关闭 SDK 的等待上限为 1.5 秒;刷新、断网和进程终止仍可丢遥测。OTLP
失败不会重放业务请求,Peer 的 not-executed、unknown 和 exact-target 规则不变。
新增 Job carrier schema 按共享契约协调升级,旧页面重新连接前需刷新到匹配版本。
9 changes: 9 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/core": "^2.11.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.222.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.222.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.222.0",
"@opentelemetry/resources": "^2.11.0",
"@opentelemetry/sdk-logs": "^0.222.0",
"@opentelemetry/sdk-metrics": "^2.11.0",
"@opentelemetry/sdk-trace-web": "^2.11.0",
"@supabase/postgrest-js": "^2.84.0",
"dayjs": "^1.11.0",
"jose": "^5.0.0",
Expand Down
29 changes: 27 additions & 2 deletions packages/core/src/base/db-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { observeOperation, SpanKind, SpanStatusCode } from '../obsrv/telemetry'
import { configStore as sharedConfigStore } from '../config'
import { authStore } from '../auth'
import { PostgrestClient } from '@supabase/postgrest-js'
Expand Down Expand Up @@ -65,7 +66,19 @@ export async function rawPostgrestFetch(
headers.set('Accept-Profile', schema)
headers.set('Content-Profile', schema)

const response = await fetch(url, { ...init, headers })
const response = await observeOperation(
'postgrest.http',
async (_active, span, outcome) => {
const result = await fetch(url, { ...init, headers })
span?.setAttribute('http.response.status_code', result.status)
if (result.status >= 400) {
outcome.outcome = 'error'
span?.setStatus({ code: SpanStatusCode.ERROR })
}
return result
},
{ kind: SpanKind.CLIENT }
)
if (!response.ok) {
const errorResponse = response.clone()
let details: unknown
Expand Down Expand Up @@ -121,7 +134,19 @@ export class DBAPIClient<
const token = await tokenProvider()
const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${token}`)
return fetch(input, { ...init, headers })
return observeOperation(
'postgrest.http',
async (_active, span, outcome) => {
const response = await fetch(input, { ...init, headers })
span?.setAttribute('http.response.status_code', response.status)
if (response.status >= 400) {
outcome.outcome = 'error'
span?.setStatus({ code: SpanStatusCode.ERROR })
}
return response
},
{ kind: SpanKind.CLIENT }
)
},
})

Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ const CurrentMetaConfigSchema = z.object({
INKCRE_PGREST_URL: UnconfiguredUrlSchema.default(''),
INKCRE_JWT_SECRET: z.string().default(''),
INKCRE_PEER_ID: GeneratedPeerIdSchema,
telemetry_enabled: z.boolean().default(false),
telemetry_peer_relay_url: z
.union([
z.literal(''),
z.url().refine((value) => {
const url = new URL(value)
return (
['http:', 'https:'].includes(url.protocol) &&
!url.username &&
!url.password &&
!url.search &&
!url.hash
)
}, 'Peer relay URL must be HTTP(S) without credentials, query or fragment'),
])
.default(''),
})

/** Preserve one browser origin's old technical identity during the Peer cutover. */
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/database/database.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,8 @@ export type Database = {
started_at: string | null
state: Json
status: Database['inkcre']['Enums']['jobstatus']
submission_traceparent: string | null
submission_tracestate: string | null
timeout_seconds: number
type: string
}
Expand All @@ -439,6 +441,8 @@ export type Database = {
started_at?: string | null
state?: Json
status?: Database['inkcre']['Enums']['jobstatus']
submission_traceparent?: string | null
submission_tracestate?: string | null
timeout_seconds: number
type: string
}
Expand All @@ -451,6 +455,8 @@ export type Database = {
started_at?: string | null
state?: Json
status?: Database['inkcre']['Enums']['jobstatus']
submission_traceparent?: string | null
submission_tracestate?: string | null
timeout_seconds?: number
type?: string
}
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,14 @@ export {
export { Cron, CronForm } from './cron'

// Observability
export { Log, type LogRef } from './obsrv'
export {
Log,
type LogRef,
initializeTelemetry,
shutdownTelemetry,
flushTelemetry,
telemetryDiagnosticsUrl,
} from './obsrv'

// Info-Base (Blocks, Relations, Storage, Resolvers)
export {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/job/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export class Job extends Z.class({
JobStatus.TIMED_OUT,
JobStatus.ABORTED,
]),
submission_traceparent: z.string().nullable().default(null),
submission_tracestate: z.string().nullable().default(null),
created_at: z.coerce.date(),
started_at: z.coerce.date().nullable(),
closed_at: z.coerce.date().nullable(),
Expand Down
Loading
Loading