659 lines
19 KiB
Markdown
659 lines
19 KiB
Markdown
# 业务接入指南
|
||
|
||
本文档说明如何将前端/后端业务接入 light-sentry 日志分析系统。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [系统架构](#1-系统架构)
|
||
2. [接入方式概览](#2-接入方式概览)
|
||
3. [前端接入(Browser SDK)](#3-前端接入browser-sdk)
|
||
4. [后端接入(Node.js SDK)](#4-后端接入nodejs-sdk)
|
||
5. [Python 接入](#5-python-接入)
|
||
6. [容器日志接入](#6-容器日志接入)
|
||
7. [项目与容器关联](#7-项目与容器关联)
|
||
8. [一键部署业务机](#8-一键部署业务机)
|
||
9. [常见问题](#9-常见问题)
|
||
|
||
---
|
||
|
||
## 1. 系统架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ log.starseekai.cn │
|
||
│ │
|
||
│ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │
|
||
│ │ /manage/ │ │ /grafana/ │ │ /dozzle/ │ │
|
||
│ │ 项目管理 │ │ 仪表盘 │ │ 容器日志查看 │ │
|
||
│ └────┬─────┘ └─────┬──────┘ └──────┬───────┘ │
|
||
│ │ │ │ │
|
||
│ ┌────▼────────────────▼────────────────▼───────┐ │
|
||
│ │ Nginx 反向代理 │ │
|
||
│ │ /api/* → API 服务 (9000) │ │
|
||
│ │ /loki/* → Loki (3100) │ │
|
||
│ └────────────────┬───────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌────────────────▼───────────────────────┐ │
|
||
│ │ Light-Sentry API 服务 │ │
|
||
│ │ - Sentry SDK 兼容网关 │ │
|
||
│ │ - 项目管理 API │ │
|
||
│ │ - 一键部署 Agent │ │
|
||
│ └────────┬──────────────┬─────────────────┘ │
|
||
│ │ │ │
|
||
│ ┌────────▼───┐ ┌───────▼──────┐ ┌─────────────┐ │
|
||
│ │ Loki │ │ Promtail │ │ Dozzle │ │
|
||
│ │ 日志存储 │ │ 中心日志采集 │ │ 容器日志查看 │ │
|
||
│ └─────────────┘ └──────────────┘ └─────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
|
||
业务机部署:
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 业务 ECS 服务器 │
|
||
│ │
|
||
│ ┌──────────────┐ ┌──────────────────┐ │
|
||
│ │ Promtail │───►│ 中心 Loki │ │
|
||
│ │ (采集容器日志) │ │ log.starseekai.cn│ │
|
||
│ └──────────────┘ └──────────────────┘ │
|
||
│ │
|
||
│ ┌──────────────┐ │
|
||
│ │ Dozzle │───► 远程连接中心 Dozzle Agent │
|
||
│ │ (本地容器日志) │ │
|
||
│ └──────────────┘ │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 服务入口
|
||
|
||
| 服务 | 地址 | 说明 |
|
||
|------|------|------|
|
||
| 项目管理 | `https://log.starseekai.cn/manage/` | 可视化管理项目、容器关联、一键部署 |
|
||
| Grafana 仪表盘 | `https://log.starseekai.cn/grafana/` | 查询 Sentry 日志、容器日志、链路追踪 |
|
||
| Dozzle 容器日志 | `https://log.starseekai.cn/dozzle/` | 实时查看业务机 Docker 容器日志 |
|
||
| Loki 查询 API | `https://log.starseekai.cn/loki/loki/api/v1/` | Loki 原生查询 API |
|
||
| SDK 上报地址 | `https://log.starseekai.cn/api/{projectId}/store/` | Sentry SDK 上报端点 |
|
||
|
||
---
|
||
|
||
## 2. 接入方式概览
|
||
|
||
light-sentry 完全兼容 Sentry SDK 协议,只需将 SDK 的 **DSN(Data Source Name)** 指向本系统即可。
|
||
|
||
### 接入矩阵
|
||
|
||
| 场景 | 推荐 SDK | 支持协议 |
|
||
|------|---------|---------|
|
||
| 前端浏览器 | Sentry Browser SDK | Store API + Envelope |
|
||
| Node.js 后端服务 | Sentry Node SDK | Store API + Envelope |
|
||
| Python 后端服务 | Sentry Python SDK | Store API + Envelope |
|
||
| Java Spring Boot | Sentry Java SDK | Store API + Envelope |
|
||
| Go 服务 | Sentry Go SDK | Store API + Envelope |
|
||
|
||
### DSN 格式
|
||
|
||
Sentry SDK 要求 DSN 格式为:
|
||
|
||
```
|
||
https://{public_key}@{host}/{projectId}
|
||
```
|
||
|
||
| 参数 | 说明 | 示例 |
|
||
|------|------|------|
|
||
| `public_key` | 认证公钥(可自定义,建议用项目名) | `order-frontend`、`backend-api` |
|
||
| `host` | 本系统的入口地址 | `log.starseekai.cn` |
|
||
| `projectId` | **必须是纯数字** | `1001`、`1002`、`1003` |
|
||
|
||
**重要:** Sentry SDK 要求 `projectId` 必须是纯数字,不支持字母或字符串 ID。
|
||
|
||
**示例 DSN:**
|
||
|
||
```bash
|
||
# 前端项目
|
||
https://order-frontend@log.starseekai.cn/1001
|
||
|
||
# 后端项目
|
||
https://order-backend@log.starseekai.cn/1002
|
||
|
||
# Python 服务
|
||
https://payment-service@log.starseekai.cn/1003
|
||
```
|
||
|
||
### 接入流程
|
||
|
||
```
|
||
1. 在项目管理页面 (https://log.starseekai.cn/manage/) 创建项目
|
||
2. 填写纯数字项目 ID(如 1001)
|
||
3. 填写 Public Key(可自定义,或留空自动生成)
|
||
4. 获取生成的 DSN
|
||
5. 在业务代码中配置 SDK
|
||
6. 容器日志:通过一键部署或手动配置 Promtail
|
||
7. 在 Grafana 仪表盘查看日志
|
||
```
|
||
|
||
### 接入建议:前后端分开
|
||
|
||
**建议将前端和后端作为两个独立项目接入:**
|
||
|
||
```
|
||
业务系统
|
||
├── 前端项目 → projectId: 1001, publicKey: myapp-frontend
|
||
└── 后端项目 → projectId: 1002, publicKey: myapp-backend
|
||
```
|
||
|
||
**分开的好处:**
|
||
|
||
| 维度 | 说明 |
|
||
|------|------|
|
||
| **日志区分** | 前后端错误分开,一目了然 |
|
||
| **采样率** | 前端可设置更高采样(用户行为重要),后端可更低(日志量大) |
|
||
| **告警规则** | 前端错误率 > 1% 告警,后端 > 5% 告警 |
|
||
| **链路追踪** | 通过 trace_id 仍能串联前后端日志 |
|
||
|
||
---
|
||
|
||
## 3. 前端接入(Browser SDK)
|
||
|
||
### 安装
|
||
|
||
```bash
|
||
npm install @sentry/browser
|
||
# 或
|
||
yarn add @sentry/browser
|
||
# 或
|
||
pnpm add @sentry/browser
|
||
```
|
||
|
||
### 基础接入(HTML 单页)
|
||
|
||
```html
|
||
<script src="https://browser.sentry-cdn.com/7.x.x/bundle.min.js" crossorigin="anonymous"></script>
|
||
<script>
|
||
Sentry.init({
|
||
dsn: 'https://frontend@log.starseekai.cn/1001',
|
||
// 推荐采样率:生产环境 10%~50%
|
||
tracesSampleRate: 0.1,
|
||
// 生产环境关闭调试
|
||
debug: false,
|
||
// 环境
|
||
environment: 'production',
|
||
// 版本(用于按版本筛选错误)
|
||
release: 'v1.2.3',
|
||
});
|
||
</script>
|
||
```
|
||
|
||
### React / Vue / Next.js 接入
|
||
|
||
**React(Create React App / Next.js):**
|
||
|
||
```javascript
|
||
import * as Sentry from '@sentry/browser';
|
||
import { BrowserTracing } from '@sentry/tracing';
|
||
|
||
Sentry.init({
|
||
dsn: 'https://frontend@log.starseekai.cn/1001',
|
||
integrations: [new BrowserTracing()],
|
||
tracesSampleRate: 0.1,
|
||
environment: process.env.NODE_ENV,
|
||
release: process.env.npm_package_version,
|
||
});
|
||
```
|
||
|
||
**Vue 3:**
|
||
|
||
```javascript
|
||
import { createApp } from 'vue';
|
||
import * as Sentry from '@sentry/browser';
|
||
import { VueIntegration } from '@sentry/integrations';
|
||
|
||
Sentry.init({
|
||
dsn: 'https://frontend@log.starseekai.cn/1001',
|
||
integrations: [new VueIntegration({ app })],
|
||
tracesSampleRate: 0.1,
|
||
environment: process.env.NODE_ENV,
|
||
});
|
||
```
|
||
|
||
### 手动上报错误
|
||
|
||
```javascript
|
||
// 手动捕获并上报
|
||
try {
|
||
// 业务代码
|
||
JSON.parse('not valid json');
|
||
} catch (err) {
|
||
Sentry.captureException(err, {
|
||
// 自定义额外数据
|
||
extra: {
|
||
userId: currentUser.id,
|
||
action: 'checkout',
|
||
},
|
||
});
|
||
}
|
||
|
||
// 上报自定义消息
|
||
Sentry.captureMessage('用户注册成功', {
|
||
level: 'info',
|
||
extra: { userId: 12345, plan: 'pro' },
|
||
});
|
||
```
|
||
|
||
### 添加用户上下文
|
||
|
||
```javascript
|
||
// 登录时设置
|
||
Sentry.setUser({
|
||
id: 'user_12345',
|
||
email: 'user@example.com',
|
||
username: 'john_doe',
|
||
ip_address: '{{auto}}', // 自动采集 IP
|
||
});
|
||
|
||
// 登出时清除
|
||
Sentry.setUser(null);
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 后端接入(Node.js SDK)
|
||
|
||
### 安装
|
||
|
||
```bash
|
||
npm install @sentry/node
|
||
# 或
|
||
yarn add @sentry/node
|
||
```
|
||
|
||
### Express 接入
|
||
|
||
```javascript
|
||
const express = require('express');
|
||
const Sentry = require('@sentry/node');
|
||
const { ExpressIntegration } = require('@sentry/integrations');
|
||
|
||
const app = express();
|
||
|
||
// Sentry 必须在其他中间件之前初始化
|
||
Sentry.init({
|
||
dsn: 'https://backend@log.starseekai.cn/1002',
|
||
integrations: [new ExpressIntegration({ app })],
|
||
tracesSampleRate: 0.1,
|
||
environment: process.env.NODE_ENV,
|
||
release: process.env.npm_package_version,
|
||
});
|
||
|
||
// 请求处理中间件(必须)
|
||
app.use(Sentry.Handlers.requestHandler());
|
||
|
||
// 你的业务路由
|
||
app.get('/api/order', (req, res) => {
|
||
// 业务代码
|
||
res.json({ orderId: 'ORD_12345' });
|
||
});
|
||
|
||
// 错误处理中间件(必须)
|
||
app.use(Sentry.Handlers.errorHandler());
|
||
|
||
app.listen(3000);
|
||
```
|
||
|
||
### 自动捕获未处理错误和 Promise rejections
|
||
|
||
```javascript
|
||
// 在 Sentry.init() 之后添加
|
||
|
||
// 捕获未处理的 Promise rejection
|
||
process.on('unhandledRejection', (reason, promise) => {
|
||
Sentry.captureException(reason);
|
||
});
|
||
|
||
// 捕获未处理的同步异常
|
||
process.on('uncaughtException', (err) => {
|
||
Sentry.captureException(err);
|
||
process.exit(1);
|
||
});
|
||
```
|
||
|
||
### 手动上报错误
|
||
|
||
```javascript
|
||
const Sentry = require('@sentry/node');
|
||
|
||
async function createOrder(req, res) {
|
||
try {
|
||
const order = await db.orders.create(req.body);
|
||
Sentry.addBreadcrumb({
|
||
category: 'db',
|
||
message: 'Order created',
|
||
data: { orderId: order.id },
|
||
});
|
||
res.json(order);
|
||
} catch (err) {
|
||
Sentry.captureException(err, {
|
||
// 附加请求上下文
|
||
contexts: {
|
||
request: {
|
||
method: req.method,
|
||
url: req.url,
|
||
headers: req.headers,
|
||
},
|
||
},
|
||
});
|
||
throw err; // 仍需抛出,让 Express 错误中间件处理
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Python 接入
|
||
|
||
### 安装
|
||
|
||
```bash
|
||
pip install sentry-sdk
|
||
```
|
||
|
||
### Django / Flask 接入
|
||
|
||
**Django:**
|
||
|
||
```python
|
||
import sentry_sdk
|
||
from sentry_sdk.integrations.django import DjangoIntegration
|
||
|
||
sentry_sdk.init(
|
||
dsn='https://python-service@log.starseekai.cn/1003',
|
||
integrations=[DjangoIntegration()],
|
||
traces_sample_rate=0.1,
|
||
environment='production',
|
||
release='v1.2.3',
|
||
)
|
||
```
|
||
|
||
**Flask:**
|
||
|
||
```python
|
||
import sentry_sdk
|
||
from sentry_sdk.integrations.flask import FlaskIntegration
|
||
|
||
sentry_sdk.init(
|
||
dsn='https://python-service@log.starseekai.cn/1003',
|
||
integrations=[FlaskIntegration()],
|
||
traces_sample_rate=0.1,
|
||
environment='production',
|
||
)
|
||
```
|
||
|
||
### 手动上报
|
||
|
||
```python
|
||
from sentry_sdk import capture_exception, capture_message
|
||
|
||
try:
|
||
1 / 0
|
||
except Exception as e:
|
||
capture_exception(e)
|
||
|
||
capture_message('用户登录成功', level='info', extra={'user_id': 12345})
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 容器日志接入
|
||
|
||
容器日志通过 Promtail 采集,存储到 Loki,可在 Grafana 和 Dozzle 中查看。
|
||
|
||
### 6.1 中心 Promtail(采集本机日志)
|
||
|
||
适用于日志分析系统所在的 ECS 服务器。
|
||
|
||
编辑 `promtail/promtail-config.yml`,修改容器白名单:
|
||
|
||
```yaml
|
||
scrape_configs:
|
||
- docker_sd_configs:
|
||
- host: unix:///var/run/docker.sock
|
||
refresh_interval: 5s
|
||
relabel_configs:
|
||
# 白名单:只收集以下前缀的容器(多个用 | 分隔)
|
||
- source_labels: ['__meta_docker_container_name']
|
||
regex: '/(light-sentry-.+|myapp-.+|nginx)'
|
||
action: keep
|
||
```
|
||
|
||
### 6.2 业务机 Promtail(一键部署)
|
||
|
||
通过项目管理页面的「一键部署」功能,自动将 Promtail Agent 部署到业务 ECS 服务器。
|
||
|
||
**部署步骤:**
|
||
|
||
1. 打开 `https://log.starseekai.cn/manage/`
|
||
2. 编辑项目,填写「关联容器名称」或「容器过滤正则」
|
||
3. 点击「一键部署」,填写业务机 SSH 信息
|
||
4. 系统自动完成:SSH 连接 → 生成配置 → 部署 Promtail → 重启 Dozzle Agent
|
||
|
||
### 6.3 Dozzle 容器过滤
|
||
|
||
Dozzle 默认只显示 `light-sentry-*` 前缀的容器。修改 `.env` 可调整:
|
||
|
||
```bash
|
||
# 格式:key=value,支持正则,多个用逗号分隔
|
||
DOZZLE_FILTER=name=light-sentry-.+|myapp-.+|nginx
|
||
```
|
||
|
||
### 6.4 Grafana 中查询容器日志
|
||
|
||
Promtail 采集的日志带以下标签:
|
||
|
||
| 标签 | 说明 |
|
||
|------|------|
|
||
| `host` | 主机名 |
|
||
| `source` | `container` 标识为容器日志 |
|
||
| `container_name` | Docker 容器名称 |
|
||
| `container_image` | Docker 镜像名称 |
|
||
| `composed_project` | Docker Compose 项目名 |
|
||
| `docker_service` | Docker Compose 服务名 |
|
||
|
||
**查询示例:**
|
||
|
||
```logql
|
||
# 按容器名查询
|
||
{container_name="myapp-web"}
|
||
|
||
# 按镜像名查询
|
||
{container_image=~"nginx.*"}
|
||
|
||
# 组合 Sentry 项目 + 容器日志
|
||
{container_name=~"myapp-.*", host="prod-ecs"}
|
||
|
||
# 查看该容器最近 1 小时的日志
|
||
{container_name="myapp-web"} |= "ERROR" | __error__!="JSONErr"
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 项目与容器关联
|
||
|
||
### 7.1 关联步骤
|
||
|
||
1. 打开项目管理页面 `https://log.starseekai.cn/manage/`
|
||
2. 编辑项目,填写容器信息:
|
||
- **关联容器名称**:精确匹配容器名,多个用逗号分隔
|
||
- **容器过滤正则**(可选):支持正则表达式,优先级更高
|
||
|
||
| 关联方式 | 示例 | 说明 |
|
||
|---------|------|------|
|
||
| 单容器 | `myapp-web` | 只采集指定容器 |
|
||
| 多容器 | `myapp-web, myapp-api` | 逗号分隔 |
|
||
| 正则匹配 | `myapp-.*` | 匹配所有 myapp- 开头的容器 |
|
||
|
||
### 7.2 接入声明
|
||
|
||
项目管理页面提供一键复制功能,包含:
|
||
|
||
- SDK 接入代码片段
|
||
- 完整的 DSN 地址
|
||
- Grafana 仪表盘跳转链接
|
||
- Dozzle 容器日志跳转链接
|
||
|
||
### 7.3 项目管理 API
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|------|------|------|
|
||
| `GET` | `/api/projects/` | 列出所有项目 |
|
||
| `GET` | `/api/projects/:id` | 获取单个项目详情 |
|
||
| `POST` | `/api/projects/` | 创建新项目 |
|
||
| `PUT` | `/api/projects/:id` | 更新项目信息 |
|
||
| `DELETE` | `/api/projects/:id` | 删除项目 |
|
||
|
||
**创建项目示例:**
|
||
|
||
```bash
|
||
curl -X POST https://log.starseekai.cn/api/projects/ \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"id": "order-service",
|
||
"name": "订单服务",
|
||
"platform": "node",
|
||
"environment": "production",
|
||
"description": "处理订单创建和支付",
|
||
"containerName": "order-service-web, order-service-worker"
|
||
}'
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 一键部署业务机
|
||
|
||
通过项目管理页面的「一键部署」功能,将 Promtail Agent 和 Dozzle Agent 自动部署到业务 ECS 服务器。
|
||
|
||
### 8.1 部署流程
|
||
|
||
```
|
||
1. 用户在页面填写业务机 SSH 信息(IP、用户名、密码)
|
||
2. 后端启动异步部署任务
|
||
3. SSH 连接业务机
|
||
4. 创建部署目录、生成 Promtail/Dozzle 配置
|
||
5. SCP 上传配置文件和 docker-compose.yml
|
||
6. 启动容器
|
||
7. 同步 Agent 信息到中心 Dozzle
|
||
8. 实时 SSE 推送部署日志到前端
|
||
```
|
||
|
||
### 8.2 部署配置说明
|
||
|
||
**Loki Push 地址**:由项目管理页面的「中心 Loki 地址」配置,默认为 `https://log.starseekai.cn/loki/loki/api/v1/push`
|
||
|
||
**部署目录**:默认 `/opt/light-sentry-agent`,可在部署时自定义
|
||
|
||
**容器过滤**:根据项目关联的容器名自动生成 Promtail 过滤正则
|
||
|
||
### 8.3 部署目录结构
|
||
|
||
```
|
||
/opt/light-sentry-agent/
|
||
├── docker-compose.yml # Dozzle Agent
|
||
├── promtail/
|
||
│ └── promtail-config.yml # Promtail 配置
|
||
└── .env # 环境变量(DOZZLE_REMOTE_AGENT)
|
||
```
|
||
|
||
### 8.4 查看部署历史
|
||
|
||
部署历史可在项目管理页面查看,包含:
|
||
|
||
- 部署时间、目标主机
|
||
- 部署状态(成功/失败)
|
||
- 实时部署日志
|
||
- 容器过滤配置
|
||
|
||
---
|
||
|
||
## 9. 常见问题
|
||
|
||
### Q: SDK 上报后看不到日志
|
||
|
||
**排查步骤:**
|
||
|
||
1. 检查浏览器控制台是否有 Sentry 报错
|
||
2. 确认 DSN 是否正确:`https://{publicKey}@log.starseekai.cn/{projectId}`
|
||
3. 确认 `projectId` 是否是纯数字(如 `1001`)
|
||
4. 在 Grafana Explore 中直接查询原始日志:
|
||
|
||
```logql
|
||
{platform="javascript"}
|
||
```
|
||
|
||
### Q: 容器日志没有采集到
|
||
|
||
**排查步骤:**
|
||
|
||
1. 确认 Promtail 容器是否在运行:`docker ps | grep promtail`
|
||
2. 检查 Promtail 日志:`docker logs light-sentry-promtail`
|
||
3. 确认容器名是否在白名单中
|
||
4. 检查 Loki 是否收到数据:`curl http://localhost:3100/loki/api/v1/label/container_name/values`
|
||
|
||
### Q: 一键部署失败
|
||
|
||
**常见原因:**
|
||
|
||
1. SSH 密码错误或 IP 无法连接
|
||
2. 目标目录权限不足(确保 SSH 用户有 `/opt` 写权限)
|
||
3. Docker 未安装或未启动
|
||
|
||
**解决方案:**
|
||
|
||
- 手动检查 SSH 连接:`ssh user@host`
|
||
- 检查目标目录权限:`ls -la /opt/`
|
||
- 查看部署日志中的详细错误信息
|
||
|
||
### Q: 生产环境建议采样率是多少?
|
||
|
||
| 环境 | tracesSampleRate | 说明 |
|
||
|------|-----------------|------|
|
||
| 开发/测试 | `1.0` | 全量采集,方便调试 |
|
||
| 预发/灰度 | `0.5` | 采集一半 |
|
||
| 生产 | `0.05 ~ 0.2` | 5%~20%,平衡性能和数据量 |
|
||
|
||
### Q: 如何在 Grafana 中切换项目查看?
|
||
|
||
在 Grafana 仪表盘顶部有 **项目** 下拉框,选择后可按项目过滤日志。仪表盘地址格式:
|
||
|
||
```
|
||
https://log.starseekai.cn/grafana/d/light-sentry-logs?var-project=1
|
||
```
|
||
|
||
其中 `var-project` 参数值为项目 ID。
|
||
|
||
### Q: 日志量会不会太大?
|
||
|
||
假设:
|
||
- 每小时 10000 次 API 请求
|
||
- tracesSampleRate = 0.1(10%)
|
||
- 每条 trace 上报 3~5 条日志
|
||
- 每条日志约 2KB
|
||
|
||
```
|
||
月流量 ≈ 10000 × 0.1 × 4 × 2KB × 24h × 30d ≈ 57 MB
|
||
```
|
||
|
||
非常轻量,2核2G 完全没压力。
|
||
|
||
### Q: 如何添加自定义标签?
|
||
|
||
```javascript
|
||
// 设置全局标签(所有事件都会带)
|
||
Sentry.setTag('server', 'production');
|
||
Sentry.setTag('region', 'cn-hangzhou');
|
||
|
||
// 设置用户级别标签
|
||
Sentry.setContext('order', {
|
||
orderId: 'ORD_12345',
|
||
amount: 99.9,
|
||
currency: 'CNY',
|
||
});
|
||
```
|