About portal-logging
portal-logging is a skill for connecting application logs to the portal’s Logs and Monitoring app, aimed at teams deploying and troubleshooting apps. It covers credentials, collection and ingestion patterns, validation, and diagnosis, while distinguishing logs from audit and billing data.
Key features
- 01Collector and in-app ingestion patterns
- 02Service identity credential setup
- 03Batch ingestion and response checks
- 04Log field and level conventions
- 05Query and troubleshooting guidance
Use cases
- 01Configure collection for container or file logs
- 02Send structured logs in batches from an app
- 03Verify ingestion and find logs in the portal
- 04Diagnose identity, permission, or stream-name issues
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ xgent-ai/ skills --skill portal-loggingAfter installing, type/portal-loggingin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/collectors.mdcontract.mdscripts/smoke-ingest.shDetails
From the project READMEportal-logging · 把 App 的日志接进「日志与监控」
一条链路四段,两种身份:
你的进程 → 采集器 → POST /svc/observability/v1/ingest/<stream> → 在「日志与监控」App 里看
(产生) (搬运) ↑ 服务身份(机器的钥匙) ↑ 用户身份(人的票)
两种身份不能互换,这是整条链路最常见的困惑来源:采集器只能写、不能查;人只能查、不能写。拿服务密钥去查会得到 401,那不是配置错。
路径约定:本 skill 里的
apps/…deploy/…docs/…都在门户仓。在你自己的 App repo 里它们不存在——它们标注的是「门户侧的事实在哪」,不是让你去打开的文件。你需要的一切在本 skill 的references/与scripts/(自包含,路径相对于本SKILL.md所在目录)。运行脚本前,在 App repo 根目录按实际加载位置设置SKILL_DIR="<本 SKILL.md 所在目录>"。
按任务读
| 任务 | 读 |
|---|---|
| 配采集器(容器 stdout / 文件 tail / 应用内直打),含多行合并与级别判定 | references/collectors.md |
| 端点、凭据、请求/响应形状、查询 API、错误码矩阵、计量 | references/contract.md |
| 只想先打通一条,验证链路是活的 | "$SKILL_DIR/scripts/smoke-ingest.sh"(改环境变量直接跑) |
0. 先判断:你要接的真的是「日志」吗
日志流是给人排障看的时间序列文本。下面这些各有专用面,塞进日志流会同时失去两边的能力:
- 审计(谁在什么时候改了什么,要可追溯、不可篡改、要留存)→ 门户审计面(
audit.write),不是日志。 - 用量/计费(要精确、要对账)→ 平台计量面。日志会被采样、会过期。
- 给用户看的消息(提醒、待办、通知)→ 门户通知域。
- 指标与链路(数值时间序列 / trace)→ 同一个服务的 OTLP 入口,形状与日志不同,见 contract.md。
判据:这条记录丢了会不会有人要赔钱或说不清责任。会 → 不是日志。
1. 三个前提(不成立时,每一步都会以「看起来成功」的方式失败)
- 平台把「日志与监控」登记为平台基础服务应用,且默认授予里含
observability.ingest。 这是平台管理员在门户控制台做的一次登记。没有它:你换令牌拿到 HTTP 200、scope字段却是空的,直到第一次写入才 403。——第一天接日志的团队九成栽在这里,先查它。 - 你的 App 在目标租户已安装且可用。 令牌里的
azp绑着你的 App key,签发时会检查「这个租户装没装你」。没装 ⇒APP_NOT_INSTALLED。语义是对的:你没在这个租户里跑,就不该有这个租户的日志。 - 你有自己的服务账号凭据。 平台部署你的服务时注入
<PREFIX>_SA_CLIENT_ID/<PREFIX>_SA_CLIENT_SECRET(<PREFIX>= 你的 appKey 大写、-换_),另有API_BASE_URL(门户内部源,换令牌用)与PORTAL_BASE_URL(门户公开源,/svc/...用)。 与门户同仓的后端没有「平台注入」这一步,同名的键在部署环境的 env 文件里——键名与用法完全一样,下文不再分叉。
不要为日志新建一套密钥,也不要把 observability.ingest 写进自己 manifest 的 serviceScopes:那是别人命名空间的 scope,写进去反而要求你把对方列进依赖;这条 scope 由平台在签发那一刻并进来,manifest 一个字都不用改。
2. 选写入身份
| 短期服务态令牌 | 长期访问密钥(xsak_ 开头) | |
|---|---|---|
| 怎么拿 | POST {API_BASE_URL}/api/tokens/service,HTTP Basic = client_id:secret,body grant_type=client_credentials + tenant_id + scope | 门户控制台 › 服务账号 › 访问密钥(平台管理员签;租户自助建的服务账号同理) |
| 有效期 | 分钟级,要缓存 + 到期前续 | 可无期限 |
| 谁用 | 你的应用代码自己打点 | 采集器进程(fluent-bit 这类不会自己换令牌的) |
| 代价 | 每次要一次 HTTP | 明文长期存在盘上,泄漏面大 ⇒ 只给 observability.ingest 这一条 scope,别多给 |
拿短期令牌时把它缓存到 expires_in - 10s,别每条日志换一次令牌——那是把日志量乘进了门户的负载。
3. 写入
POST {PORTAL_BASE_URL}/svc/observability/v1/ingest/<stream>
Authorization: Bearer <服务令牌 或 xsak_…>
Content-Type: application/json
[ {…记录1…}, {…记录2…} ] ← 一次一批,别一条一个请求
成功判据要看两层:HTTP 200 且 响应体 code:200 且 status[] 里 failed:0。只看 HTTP 状态码会把「收下了但没落库」当成功——这正是「写入一直 200、界面永远查不到」的来源。
⚠️ 采集器代发(形态 A/B)时你根本看不到这个响应:采集器只在自己的日志里留一行 HTTP status=200,failed>0 那种「收下了没落库」它不会告诉你,401/403 也只是按重试上限丢掉——采集器不报错 ≠ 接通了。所以这两种形态的验收落在 §8 冒烟那一条 curl(那里你拿得到响应体)加上界面真的搜到;排障时再临时打开采集器的响应打印(见 collectors.md)。
流名:你写 <stream>,实际落在 app_<你的 appKey>_<stream>——前缀由令牌里的 azp 决定,你写不进别人的命名空间,也别在 <stream> 里再重复一遍自己的 key(会得到 app_foo_foo_console)。一个 App 按用途分流即可:console(进程标准输出)、access、job。
4. 字段口径(跨 App 一致,界面和排障才有共同语言)
| 字段 | 必要性 | 说明 |
|---|---|---|
message | 必备 | 正文,一条一行 |
level | 强烈建议 | info / warn / error;产生方给的最准,采集器只能猜 |
service | 强烈建议 | 进程/子服务名,一个 App 有多个进程时靠它分 |
host | 建议 | 实例/主机名,多副本时靠它分 |
fd | 采集器填 | stdout / stderr |
时间:落库的时间轴是到达时间,日志自带的时间只是一个普通字段。要让时间轴等于事件时间,就在记录里带上 _timestamp(微秒 epoch),并先用一条冒烟记录验证这个版本认不认——别假设。
级别:如果你的日志是文本行、由采集器搬运,采集器只能靠 stderr 和关键词猜,而 console.warn 一类在多数运行时也走 stderr ⇒ 例行 warn 会整批被判成 error。解法是产生方把级别写进正文(例如正文尾部的 JSON 里 "lvl":"error"),采集器优先取它。判定次序与可直接抄的实现见 collectors.md。
永远不要写进日志:令牌 / 密钥 / 密码、完整 query string(授权码与令牌常在里面)、用户个人信息与租户业务正文、大 blob。排障要的是能定位的 ID,不是内容本身——日志会被转发、留存、被平台管理员看到。
5. 选采集形态
| 形态 | 适合 | 代价 |
|---|---|---|
| A 容器 stdout → 日志驱动 → 采集器 | 容器化交付、应用零改动 | 多一个采集器进程 |
| B 采集器 tail 日志文件 | 进程管理器把 stdout 写成文件的部署 | 要处理轮转 |
| C 应用内直接批量 POST | 想要结构化字段与精确级别、日志量小 | 自己管批量/重试/背压 |
共性的四条(三种形态都适用,任一漏掉都会在生产上现形):批量发、异步发(不许在请求热路径上同步等远端)、失败丢弃而不是无限重试(日志不值得拖垮主流程)、多行合并(一个栈是一条记录,不是四十条)。
⚠️ 多行合并按 tag/流分缓冲。多个日志源共用一个 tag 时,A 源的栈会把 B 源随后那行吸进同一条记录——每个源(每个文件 / 每个容器 × stdout|stderr)一个 tag。
配置照抄 references/collectors.md。
6. 查
正常路径是人打开「日志与监控」App 查(需要 observability.read,由租户管理员同意授权)。程序化查询要用户身份的令牌,服务密钥打查询面是 401——先确认你是不是拿错了钥匙,再怀疑数据没进去。
7. 排错矩阵
| 症状 | 多半是 | 动作 |
|---|---|---|
换令牌 200,但响应里 scope 是空的 | 平台没登记基础服务应用 / 默认授予里没有 ingest | 找平台管理员登记,§1.1 |
写入 403 | 身份类型不对(拿用户票写)或 scope 里没有 ingest | 用服务身份;查签出来的 scope |
写入 401 | 凭证缺失 / 伪造 / 已撤销 / 已过期 | 重签;轮换过的旧密钥立刻失效 |
换令牌报 APP_NOT_INSTALLED | 你的 App 没装在这个租户 | 这是设计,不是 bug;换租户或先装 |
| 写入 200,但查不到 | ① 只看了 HTTP 没看 status[];② 流名前缀猜错(是 app_<key>_<stream>);③ 时间范围——时间轴是到达时间 | 按序查这三条 |
查询报 stream not found | 这个流一条都没写进去过 | 回到上一行 |
| 查询 401、但写入 200 | 拿服务密钥查 | 换用户身份 |
| 只看到零星几条 | 时间范围太窄;或你的进程本来就几乎不打日志 | 先确认源头有没有行 |
| 一次报错被拆成几十条 | 采集器没配多行合并 | collectors.md |
| 一条记录里混了两个进程的行 | 多个源共用一个 tag | 每源一个 tag |
| 界面里只有别的 App 的流 | 你的令牌 azp 不是你以为的那个 | 自省一下令牌 |
8. 冒烟验收(接完逐条走一遍,都过才算接上)
- 换出来的令牌
scope确实含observability.ingest(不是空)。 - 写一条带标记的记录,响应 HTTP 200 且
status[]里failed:0。 - 在「日志与监控」App 里按这个标记搜到了它,流名是
app_<你的 key>_<stream>。 - 故意打一个多行栈,界面里它是一条记录。
- 打一条 error 和一条 info,界面里级别分得开。
- 把采集器停掉再起来,不重放历史、也不丢新行。
别做的事
- 别为日志自建一套网关/存储/密钥体系——这条链路已经存在,重造的那份不会有人运维。
- 别把审计、用量、通知塞进日志流(§0)。
- 别在生产刷 debug 级别:写入是计量项(按字节),也会把有用的行淹掉。
- 别把别的 App 的日志代写进自己的流:
azp决定前缀,代写要么被拒,要么污染自己的命名空间。