第十七章:集成(Integrations)开发指南
- 17.1 Sentry 集成体系概览
- 17.2 IntegrationProvider 与安装管道
- 17.3 IntegrationInstallation 运行时实例
- 17.4 核心 Mixin 体系
- 17.5 已有集成深度分析
- 17.6 Webhook 处理机制
- 17.7 Sentry App 开发
- 17.8 Plugin 体系
- 17.9 开发一个完整的 Integration —— 示例
- 17.10 测试策略
- 17.11 发布与上线
17.1 Sentry 集成体系概览
Sentry 的集成体系是整个平台生态的核心支柱。通过集成,Sentry 能够与 GitHub、GitLab、Slack、Jira、Vercel、PagerDuty 等数十种第三方工具深度对接,实现从代码提交追踪、问题同步管理到告警通知分发的全链路自动化。
在 Sentry 代码库中,集成相关代码主要分布在三个核心目录:
| 目录 | 职责 |
|---|---|
src/sentry/integrations/ |
Integration 体系(推荐方式) |
src/sentry/sentry_apps/ |
Sentry App 体系(公开 API 平台) |
src/sentry/plugins/ |
Plugin 体系(遗留方式) |
17.1.1 三种集成方式对比
| 维度 | Integration | Sentry App | Plugin |
|---|---|---|---|
| 定位 | 官方/深度集成 | 第三方开发者平台 | 遗留扩展机制 |
| 安装方式 | 组织级安装 | OAuth 授权安装 | 项目级配置 |
| 数据存储 | 独立模型(Integration + OrganizationIntegration) | SentryApp + SentryAppInstallation | 项目配置 JSON |
| UI 扩展 | 有限(配置面板) | 丰富(Issue 面板、设置页、Stacktrace 链接) | 有限 |
| 认证方式 | Identity Provider / API Key / OAuth | OAuth 2.0 | API Key |
| Webhook | 自定义端点 | 统一 Webhook 分发 | 无标准机制 |
| 适用场景 | 官方深度整合 | 生态开发者扩展 | 简单数据转发 |
核心区别在于 隔离级别:
- Integration 由 Sentry 团队在核心代码库中维护,与平台深度耦合,可以访问内部 API 和数据模型。
- Sentry App 运行在外部,通过公开 REST API 和 Webhook 与 Sentry 交互,遵循最小权限原则。
- Plugin 是 Sentry 早期(v8 之前)的扩展机制,当前已不再推荐新开发使用。
17.1.2 IntegrationDomain 分类体系
Sentry 将所有集成按业务领域分为五大类别(src/sentry/integrations/base.py:137-143):
class IntegrationDomain(StrEnum):
MESSAGING = "messaging" # 即时通讯:Slack、Discord、MS Teams
PROJECT_MANAGEMENT = "project_management" # 项目管理:Jira、Jira Server
SOURCE_CODE_MANAGEMENT = "source_code_management" # 源码管理:GitHub、GitLab、Bitbucket、Azure DevOps
ON_CALL_SCHEDULING = "on_call_scheduling" # 值班调度:PagerDuty、OpsGenie
IDENTITY = "identity" # 身份认证管道
GENERAL = "general" # 跨领域通用
每种领域在代码中都有对应的 Mixin 或抽象基类来提供领域特定行为。例如,SOURCE_CODE_MANAGEMENT 领域的集成会实现 RepositoryIntegration 接口,而 MESSAGING 领域的集成则实现 IntegrationNotificationClient。
17.1.3 IntegrationFeatures 功能矩阵
IntegrationFeatures(src/sentry/integrations/base.py:103-133)定义了集成可以声明的能力集合:
class IntegrationFeatures(StrEnum):
ALERT_RULE = "alert-rule" # 告警规则通知
CHAT_UNFURL = "chat-unfurl" # 聊天链接展开
COMMITS = "commits" # 提交数据关联
ISSUE_BASIC = "issue-basic" # 基本问题链接
ISSUE_SYNC = "issue-sync" # 问题双向同步
STACKTRACE_LINK = "stacktrace-link" # 堆栈跟踪链接
CODEOWNERS = "codeowners" # CODEOWNERS 文件
TICKET_RULES = "ticket-rules" # 自动创建工单
SERVERLESS = "serverless" # 无服务器部署
MOBILE = "mobile" # 移动端
DATA_FORWARDING = "data-forwarding" # 数据转发(Plugin 专用)
DEPLOYMENT = "deployment" # 部署集成
CODING_AGENT = "coding-agent" # 编码代理
MONITORING = "monitoring" # 监控
每个功能都可以通过组织级的 Feature Flag 独立开启或关闭。Gate 规则为:如果 feature.requires_feature_flag = True,则检查 organizations:integrations-{feature.value} 标志。
17.1.4 集成管理层 —— IntegrationManager
所有 Integration 通过 IntegrationManager(src/sentry/integrations/manager.py)统一注册和查找:
class IntegrationManager:
def __init__(self):
self.__values: dict[str, type[IntegrationProvider]] = {}
def register(self, cls: type[IntegrationProvider]) -> None:
self.__values[cls.key] = cls
def get(self, key: str) -> IntegrationProvider:
try:
cls = self.__values[key]
except KeyError:
raise NotRegistered(key)
return cls()
def all(self) -> Iterable[IntegrationProvider]:
for key in self.__values.keys():
integration = self.get(key)
if integration.visible:
yield integration
# 全局单例
default_manager = IntegrationManager()
all = default_manager.all
get = default_manager.get
register = default_manager.register
这个模式简单但有效:每个 IntegrationProvider 子类在模块导入时通过 register(MyIntegrationProvider) 注册自身,运行时即可通过 key 字符串动态获取实例。
17.2 IntegrationProvider 与安装管道
17.2.1 IntegrationProvider 基类
IntegrationProvider(src/sentry/integrations/base.py:191)是集成的人口类,定义了一个第三方服务如何被发现、如何安装以及安装时需要哪些配置。关键属性如下:
| 属性 | 类型 | 说明 |
|---|---|---|
key |
str | 唯一标识符,全局注册键 |
name |
str | 显示名称 |
metadata |
IntegrationMetadata | 描述、作者、功能列表等元数据 |
integration_cls |
type[IntegrationInstallation] | 安装后的运行时类 |
features |
frozenset[IntegrationFeatures] | 支持的功能集 |
can_add |
bool | 是否允许从 Sentry 内部发起安装 |
can_add_externally |
bool | 是否允许从第三方平台发起安装 |
can_disable |
bool | 是否可以在 Sentry 内卸载 |
needs_default_identity |
bool | 是否需要关联用户身份 |
requires_feature_flag |
bool | 是否受 Feature Flag 控制 |
其中 can_add 和 can_add_externally 的组合决定了安装入口:
can_add=True, can_add_externally=False:仅从 Sentry 内部安装(如 GitHub)can_add=False, can_add_externally=True:仅从第三方平台发起(如 Vercel、MS Teams)can_add=True, can_add_externally=True:两种方式均可(如 Slack)
17.2.2 IntegrationMetadata 元数据
IntegrationMetadata 是一个 NamedTuple,包含了在 Sentry UI 中展示集成卡片所需的信息:
class IntegrationMetadata(NamedTuple):
description: str # Markdown 格式的描述文本
features: Sequence[FeatureDescription] # 功能列表
author: str # 作者名称
noun: str # 安装单元的称谓(如 "Installation"、"Workspace")
issue_url: str # 问题反馈链接
source_url: str # 源代码链接
aspects: dict[str, Any] # 扩展配置(如外部安装入口)
class FeatureDescription(NamedTuple):
description: str # 功能的 Markdown 描述
featureGate: IntegrationFeatures # 对应的功能开关
以 Slack 集成为例(src/sentry/integrations/slack/integration.py:55-81):
FEATURES = [
FeatureDescription(
"""
Unfurls Sentry URLs directly within Slack, providing you context and
actionability on issues right at your fingertips.
""",
IntegrationFeatures.CHAT_UNFURL,
),
FeatureDescription(
"""
Configure rule based Slack notifications to automatically be posted into a
specific channel.
""",
IntegrationFeatures.ALERT_RULE,
),
]
metadata = IntegrationMetadata(
description=DESCRIPTION.strip(),
features=FEATURES,
author="The Sentry Team",
noun=_("Workspace"),
issue_url="https://github.com/getsentry/sentry/issues/new?...",
source_url="https://github.com/getsentry/sentry/tree/master/src/sentry/integrations/slack",
aspects={},
)
17.2.3 安装管道(IntegrationPipeline)
安装流程由 IntegrationPipeline(src/sentry/integrations/pipeline.py:127)驱动,它继承自通用的 Pipeline 基类。安装过程分为以下阶段:
初始化 → 验证权限 → 执行管道步骤 → finish_pipeline → post_install
initialize_integration_pipeline() 函数在管道启动时执行以下预检查(pipeline.py:60-103):
- Feature Flag 检查:验证
requires_feature_flag对应的开关是否启用 - 功能特性检查:至少一个功能 Flag 可用
- 安装能力检查:
can_add或can_add_externally至少一个为 True
核心的 finish_pipeline() 方法(pipeline.py:168-239):
def finish_pipeline(self) -> HttpResponseBase:
# 1. 权限检查:用户必须是 org owner/manager/admin
# 2. 调用 provider.build_integration(state) 构建 IntegrationData
# 3. 调用 _finish_pipeline(data) 完成安装
# a. 创建/更新 Integration 数据库记录
# b. 关联 Identity(如果有 user_identity)
# c. 创建 OrganizationIntegration 关联记录
# 4. 创建审计日志
# 5. 调用 provider.post_install()
# 6. 返回 dialog_success 响应
_install_integration() 是核心的数据持久化方法。它处理以下场景:
def _install_integration(self, data: IntegrationData) -> OrganizationIntegration:
# 如果 data 包含 expect_exists,直接查找已有 Integration
if "expect_exists" in data:
self.integration = Integration.objects.get(
provider=self.provider.integration_key,
external_id=data["external_id"],
)
else:
# 否则调用 ensure_integration 创建或更新
self.integration = ensure_integration(self.provider.integration_key, data)
# 处理用户身份 Identity
identity = data.get("user_identity")
if identity:
idp = IdentityProvider.objects.get_or_create(
external_id=data.get("idp_external_id", data["external_id"]),
type=identity["type"],
)
identity_model = Identity.objects.link_identity(
user=self.request.user,
idp=idp,
external_id=identity["external_id"],
)
# 创建 OrganizationIntegration
org_integration = self.integration.add_organization(
self.organization, self.request.user,
default_auth_id=identity_model.id if self.provider.needs_default_identity else None,
)
return org_integration
17.2.4 API 管道步骤(ApiPipelineSteps)
Sentry 的集成安装已全面迁移到 API 驱动的管道模式。每个步骤为一个实现了以下协议的对象:
class ApiPipelineStep:
step_name: str # 步骤标识符
def get_step_data(self, pipeline, request) -> dict:
"""GET 请求时返回初始数据"""
def get_serializer_cls(self) -> type | None:
"""返回验证 POST 数据的序列化器类"""
def handle_post(self, validated_data, pipeline, request) -> PipelineStepResult:
"""处理 POST 请求,返回 advance/error/stay/complete"""
步骤之间的状态通过 pipeline.bind_state(key, value) 传递。以 GitHub 集成的两步管道为例:
第一步:OAuthLoginApiStep(授权登录)
class OAuthLoginApiStep:
step_name = "oauth_login"
def get_step_data(self, pipeline, request):
return {"oauthUrl": build_github_oauth_url(pipeline)}
def handle_post(self, validated_data, pipeline, request):
# 1. 验证 state 签名
if validated_data["state"] != pipeline.signature:
return PipelineStepResult.error("Invalid state")
# 2. 用 code 换取 access_token
result = exchange_github_oauth(code=validated_data["code"])
# 3. 绑定状态供下一步使用
pipeline.bind_state("github_authenticated_user", result.authenticated_user)
pipeline.bind_state("existing_installation_info", result.installation_info)
return PipelineStepResult.advance()
第二步:GithubOrganizationSelectionApiStep(组织选择)
class GithubOrganizationSelectionApiStep:
step_name = "org_selection"
def get_step_data(self, pipeline, request):
installations = pipeline.fetch_state("existing_installation_info")
return {
"installAppUrl": get_install_app_url(),
"installationInfo": [
{"installationId": i["installation_id"], "githubAccount": i["github_account"]}
for i in (installations or [])
],
}
def handle_post(self, validated_data, pipeline, request):
# 用户选择了某个已有安装
if chosen_id := validated_data.get("chosen_installation_id"):
validate_org_installation_choice(chosen_id, pipeline)
pipeline.bind_state("installation_id", chosen_id)
# 用户完成了 GitHub App 安装弹窗
if post_id := validated_data.get("installation_id"):
pipeline.bind_state("installation_id", post_id)
# 验证 installation 有效性
installation_id = validate_github_installation(pipeline)
if installation_id is None:
return PipelineStepResult.stay(data={"installAppUrl": get_install_app_url()})
return PipelineStepResult.advance()
17.2.5 build_integration 与数据持久化
build_integration(state) 是每个 IntegrationProvider 必须实现的方法,它将管道中累积的状态转换为数据库记录。返回值 IntegrationData 的结构如下:
class IntegrationData(TypedDict):
external_id: str # 必填:第三方服务的唯一标识
name: NotRequired[str] # 显示名称
metadata: NotRequired[dict[str, Any]] # 集成级元数据(API token 等)
post_install_data: NotRequired[dict[str, Any]] # 传给 post_install 的数据
expect_exists: NotRequired[bool] # 如果为 True,只查找不创建
user_identity: NotRequired[_UserIdentity] # 安装者的身份信息
以 Slack 为例(src/sentry/integrations/slack/integration.py:392-427):
def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
data = state["oauth_data"]
return {
"name": data["team"]["name"],
"external_id": data["team"]["id"],
"metadata": {
"access_token": data["access_token"],
"scopes": sorted(scopes),
"icon": team_data["icon"]["image_132"],
"domain_name": team_data["domain"] + ".slack.com",
"installation_type": "born_as_bot",
},
"user_identity": {
"type": "slack",
"external_id": data["authed_user"]["id"],
"scopes": [],
"data": {},
},
}
external_id 是三方服务中唯一标识本次安装的值(GitHub installation_id、Slack team_id、Jira 实例 URL 等),用于去重和多组织共享。
17.2.6 post_install 安装后处理
post_install 在数据库记录创建后执行,用于触发异步初始化任务。典型用途包括:
- GitHub:异步链接仓库、迁移已有 repo
- Slack:异步链接用户身份(
link_slack_user_identities) - Vercel:创建 Internal Integration(Sentry App)用于 Release Token
- MS Teams:向安装的频道发送欢迎卡片
# GitHub 示例 (github/integration.py:718-746)
def post_install(self, integration, organization, *, extra):
# 异步迁移已有的 repos
repos = repository_service.get_repositories(
organization_id=organization.id, providers=[self.key], has_integration=False
)
for repo in repos:
migrate_repo.apply_async(kwargs={
"repo_id": repo.id, "integration_id": integration.id,
"organization_id": organization.id,
})
# 异步链接所有可访问的 repos
link_all_repos.apply_async(kwargs={
"integration_key": self.key, "integration_id": integration.id,
"organization_id": organization.id,
})
17.3 IntegrationInstallation 运行时实例
17.3.1 实例构造与初始化
IntegrationInstallation(src/sentry/integrations/base.py:380)是集成安装后的运行时对象。每次与第三方服务交互时,都会实例化一个新的 IntegrationInstallation 对象:
class IntegrationInstallation(abc.ABC):
def __init__(self, model: RpcIntegration | Integration, organization_id: int) -> None:
self.model = model # Integration 模型实例
self.organization_id = organization_id # 组织 ID
@cached_property
def org_integration(self) -> RpcOrganizationIntegration:
"""缓存的组织-集成关联记录"""
return integration_service.get_organization_integration(
integration_id=self.model.id, organization_id=self.organization_id,
)
@cached_property
def organization(self) -> RpcOrganization:
"""缓存的 RpcOrganization"""
return organization_service.get(id=self.organization_id)
通过 IntegrationProvider.get_installation() 工厂方法创建实例:
@classmethod
def get_installation(cls, model, organization_id, **kwargs):
return cls.integration_cls(model, organization_id, **kwargs)
17.3.2 get_client —— 获取 API 客户端
get_client() 是每个集成的核心方法,返回与第三方 API 交互的客户端对象。客户端封装了认证、请求重试、错误处理等逻辑:
# GitHub - 使用 installation token
class GitHubIntegration(RepositoryIntegration, ...):
def get_client(self) -> GitHubBaseClient:
return GitHubApiClient(
integration=self.model,
org_integration_id=self.org_integration.id,
)
# Slack - 使用 SlackSdkClient
class SlackIntegration(IntegrationInstallation):
def get_client(self) -> SlackSdkClient:
return SlackSdkClient(integration_id=self.model.id)
# Jira - 使用 OAuth 签名
class JiraIntegration(IssueSyncIntegration):
def get_client(self) -> JiraCloudClient:
return JiraCloudClient(
integration=self.model,
verify_ssl=True,
)
17.3.3 组织级配置(get_organization_config)
get_organization_config() 返回一个 JSONForm 描述符列表,用于在 Sentry UI 中渲染每个组织的集成配置面板。配置字段类型包括:
| 类型 | 说明 | 示例 |
|---|---|---|
boolean |
开关 | “Sync Sentry Comments to GitHub” |
select |
下拉选择 | 解决策略选择 |
choice_mapper |
动态键值映射 | 项目-状态映射 |
textarea |
多行文本 | 忽略字段列表 |
project_mapper |
项目映射器 | Vercel 项目关联 |
GitHub 集成的配置示例(部分):
def get_organization_config(self) -> list[dict[str, Any]]:
config = [
{
"name": self.outbound_status_key,
"type": "choice_mapper",
"label": "Sync Sentry Status to GitHub",
"help": "When a Sentry issue changes status...",
"addButtonText": "Add GitHub Project",
"addDropdown": {
"emptyMessage": "All projects configured",
"items": current_repo_items, # 已配置的项目
"url": reverse("sentry-integration-github-search", ...),
},
"mappedSelectors": {
"on_resolve": {"choices": [("open", "Open"), ("closed", "Closed")]},
"on_unresolve": {"choices": [("open", "Open"), ("closed", "Closed")]},
},
},
{"name": self.comment_key, "type": "boolean", "label": "Sync Sentry Comments to GitHub"},
{"name": "pr_comments", "type": "boolean", "label": "Enable Comments on Suspect Pull Requests"},
]
return config
17.3.4 配置更新(update_organization_config)
update_organization_config(data) 负责处理前端提交的配置变更,通常涉及复杂的验证和数据转换。Jira 集成的实现展示了典型模式:
def update_organization_config(self, data):
config = self.org_integration.config
if "sync_status_forward" in data:
project_mappings = data.pop("sync_status_forward")
# 验证必填字段
if any(not m["on_unresolve"] or not m["on_resolve"]
for m in project_mappings.values()):
raise IntegrationError("Resolve and unresolve status are required.")
# 清空并重建 IntegrationExternalProject 记录
IntegrationExternalProject.objects.filter(
organization_integration_id=self.org_integration.id
).delete()
for project_id, statuses in project_mappings.items():
IntegrationExternalProject.objects.create(
organization_integration_id=self.org_integration.id,
external_id=project_id,
resolved_status=statuses["on_resolve"],
unresolved_status=statuses["on_unresolve"],
)
# 合并并持久化
config.update(data)
org_integration = integration_service.update_organization_integration(
org_integration_id=self.org_integration.id, config=config,
)
self.org_integration = org_integration
17.3.5 卸载与调试
卸载:uninstall() 方法在集成被移除时调用,用于清理第三方服务上的资源:
# Vercel 集成卸载时调用 Vercel API 移除 webhook
def uninstall(self):
client = self.get_client()
try:
client.uninstall(self.get_configuration_id())
except ApiError as error:
if error.code == 403:
pass # 已卸载,忽略
else:
raise
调试元数据:通过 _get_debug_metadata_keys() 暴露非敏感字段供管理端点和日志使用:
# GitHub
def _get_debug_metadata_keys(self) -> list[str]:
return ["account_type", "domain_name", "permissions"]
# Slack
def _get_debug_metadata_keys(self) -> list[str]:
return ["domain_name", "installation_type", "scopes"]
17.4 核心 Mixin 体系
Sentry 通过 Mixin 模式为 Integration 提供可组合的功能模块。每个 Mixin 都是一个抽象基类,定义了特定领域接口。
17.4.1 IssueBasicIntegration —— 问题基础集成
IssueBasicIntegration(src/sentry/integrations/mixins/issues.py:72)提供将 Sentry Issue 链接到外部工单的基础能力:
class IssueBasicIntegration(IntegrationInstallation, ABC):
@abstractmethod
def get_issue_url(self, key: str) -> str:
"""给定外部 issue key,返回外部 issue 链接"""
def get_group_body(self, group, event, **kwargs):
"""构建 issue 描述文本"""
def get_group_title(self, group, event, **kwargs):
"""构建 issue 标题"""
# 创建和链接 issue 的配置
def get_create_issue_config(self, group, user, **kwargs):
"""返回创建 issue 时的表单配置"""
def get_link_issue_config(self, group, **kwargs):
"""返回链接已有 issue 时的表单配置"""
@abstractmethod
def create_issue(self, data, **kwargs):
"""在外部服务创建 issue"""
@abstractmethod
def get_issue(self, issue_id, **kwargs):
"""获取外部 issue 详情"""
@abstractmethod
def search_issues(self, query, **kwargs):
"""搜索外部 issue"""
17.4.2 IssueSyncIntegration —— 问题同步集成
IssueSyncIntegration 扩展了 IssueBasicIntegration,增加了双向同步能力:
class IssueSyncIntegration(IssueBasicIntegration, ABC):
outbound_status_key = "sync_status_forward" # 状态传出
inbound_status_key = "sync_status_reverse" # 状态传入
outbound_assignee_key = "sync_forward_assignment" # 分配传出
inbound_assignee_key = "sync_reverse_assignment" # 分配传入
comment_key = "sync_comments" # 评论同步
@abstractmethod
def sync_assignee_outbound(self, external_issue, user, assign=True, **kwargs):
"""将 Sentry 的分配变更同步到外部"""
@abstractmethod
def sync_status_outbound(self, external_issue, is_resolved, project_id):
"""将 Sentry 的状态变更同步到外部"""
@abstractmethod
def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction:
"""根据外部 webhook 数据判断应执行的状态动作"""
def sync_status_inbound(self, issue_key, data):
"""从外部状态变更触发 Sentry 状态更新(通过异步任务)"""
def create_comment(self, issue_id, user_id, group_note):
"""在外部 issue 上创建评论"""
def update_comment(self, issue_id, user_id, group_note):
"""更新外部 issue 上的评论"""
ResolveSyncAction 枚举定义了三种同步动作:
class ResolveSyncAction(enum.Enum):
NOOP = 0 # 不执行任何操作
RESOLVE = 1 # 解决 Sentry Issue
UNRESOLVE = 2 # 取消解决 Sentry Issue
@classmethod
def from_resolve_unresolve(cls, should_resolve, should_unresolve):
if should_resolve and should_unresolve:
return ResolveSyncAction.NOOP # 冲突情况不做操作
if should_resolve:
return ResolveSyncAction.RESOLVE
if should_unresolve:
return ResolveSyncAction.UNRESOLVE
return ResolveSyncAction.NOOP
17.4.3 RepositoryIntegration —— 仓库集成
RepositoryIntegration(src/sentry/integrations/source_code_management/repository.py)定义了源码管理集成的统一接口:
class RepositoryIntegration(IntegrationInstallation, ABC):
@abstractmethod
def get_repositories(self, query=None, ...) -> list[RepositoryInfo]:
"""获取可访问的仓库列表"""
@abstractmethod
def has_repo_access(self, repo: RpcRepository) -> bool:
"""检查对特定仓库的访问权限"""
@abstractmethod
def source_url_matches(self, url: str) -> bool:
"""判断 URL 是否属于该集成"""
@abstractmethod
def format_source_url(self, repo, filepath, branch) -> str:
"""格式化源码链接(用于 stacktrace linking)"""
@abstractmethod
def extract_branch_from_source_url(self, repo, url) -> str:
"""从源码 URL 中提取分支名"""
@abstractmethod
def extract_source_path_from_source_url(self, repo, url) -> str:
"""从源码 URL 中提取文件路径"""
def is_broken_integration_error(self, exc) -> HaltReason | None:
"""判断错误是否表示集成已损坏(被限速/暂停/未授权)"""
17.4.4 NotifyBasicMixin —— 通知基础混入
NotifyBasicMixin(src/sentry/integrations/mixins/notifications.py)为集成提供通知发送能力,通常与 IntegrationNotificationClient 搭配使用:
class IntegrationNotificationClient:
def send_notification(self, target: IntegrationNotificationTarget, payload) -> None:
"""向指定目标发送通知"""
def send_notification_with_threading(
self, target, payload, threading_context
) -> dict[str, Any]:
"""发送带线程上下文的通知"""
17.5 已有集成深度分析
17.5.1 GitHub 集成 —— 源码管理集成的标杆
GitHub 集成(src/sentry/integrations/github/)是 Sentry 中最复杂、功能最全面的集成之一。它的 GitHubIntegration 类同时继承了四个 Mixin:
class GitHubIntegration(
RepositoryIntegration[GitHubBaseClient],
GitHubIssuesSpec,
GitHubIssueSyncSpec,
CommitContextIntegration,
RepoTreesIntegration,
):
核心功能:
- COMMITS:将 GitHub 提交数据关联到 Sentry Release
- ISSUE_BASIC:创建和链接 GitHub Issue/PR
- ISSUE_SYNC:双向同步状态、分配、评论
- STACKTRACE_LINK:堆栈帧链接到 GitHub 源码
- CODEOWNERS:导入 CODEOWNERS 文件用于 Issue 分配
- TICKET_RULES:基于告警规则自动创建 Issue
- PR Comments:在可疑 PR 上自动添加评论
安装管道亮点:
GitHub 的安装流程展示了多组织支持(SCM multi-org)的典型实现。当用户拥有多个 GitHub 组织且每个组织都安装了 Sentry App 时,_get_eligible_multi_org_installations() 会列出用户有权限的所有可用安装:
def _get_eligible_multi_org_installations(client, owner_orgs):
installed_orgs = client.get_user_info_installations()
return [
{
"installation_id": str(installation.get("id")),
"github_account": installation.get("account").get("login"),
"avatar_url": installation.get("account").get("avatar_url"),
}
for installation in installed_orgs["installations"]
if (installation.get("account").get("login") in owner_orgs
or installation.get("target_type") == "User")
]
Repository 查询:get_repositories() 支持多种查询模式:
def get_repositories(self, query=None, page_number_limit=None,
accessible_only=False, use_cache=False,
raise_on_page_limit=False, parallel=False):
# 1. 无查询或 accessible_only: 使用 Installation API 获取全部可访问仓库
# 2. 有查询 + accessible_only=False: 使用 GitHub Search API
# 3. use_cache=True: 使用带缓存的批量获取
# 4. parallel=True: 并发获取多页结果
17.5.2 Slack 集成 —— 即时通讯集成的范例
Slack 集成(src/sentry/integrations/slack/)展示了消息型集成的设计模式。它同时实现了 IntegrationInstallation 和 IntegrationNotificationClient:
class SlackIntegration(NotifyBasicMixin, IntegrationInstallation, IntegrationNotificationClient):
def send_notification(self, target, payload):
client = self.get_client()
client.chat_postMessage(
channel=target.resource_id,
blocks=payload["blocks"],
text=payload["text"],
attachments=payload.get("attachments"),
unfurl_links=False,
unfurl_media=False,
)
OAuth 范围管理:Slack 集成支持精细的 OAuth 范围控制:
class SlackIntegrationProvider(IntegrationProvider):
identity_oauth_scopes = frozenset([
"channels:read", "channels:history", "groups:read",
"users:read", "chat:write", "links:read", "links:write",
"team:read", "im:read", "im:history", "commands",
"chat:write.public", "chat:write.customize",
])
staging_oauth_scopes: frozenset[str] = frozenset() # 暂存新范围
user_scopes = frozenset(["links:read", "users:read", "users:read.email"])
staging_oauth_scopes 机制允许在暂存环境中测试新范围再推广到生产。
消息构建器:Slack 使用结构化的 SlackRenderable 类型来构建 Block Kit 消息:
class SlackRenderable(TypedDict):
blocks: list[dict[str, Any]]
text: str
attachments: NotRequired[list[dict[str, Any]]]
17.5.3 Jira 集成 —— 项目管理集成的复杂度
Jira 集成(src/sentry/integrations/jira/)是最复杂的项目管理集成,展示了大量高级模式:
动态表单构建:get_create_issue_config() 动态查询 Jira 的 Issue 创建元数据(字段类型、允许值、必填项),将其转换为 Sentry 的表单配置:
def get_create_issue_config(self, group, user, **kwargs):
# 1. 获取 Jira project 列表
# 2. 获取指定 project 的 issue create metadata
# 3. 构建动态字段:
# - select 类型字段 → 查询 allowedValues
# - user/team 类型 → 设置 autocomplete URL
# - sprint/epic → 提供搜索端点
# - textarea 类型 → 使用 textarea 控件
# - array 类型 → multiple select
# 4. 应用排序 (anti_gravity): priority → fixVersions → components → ...
反向状态同步:Jira 的 webhook 处理器展示了如何从外部平台的状态变更触发 Sentry 的状态更新:
def get_resolve_sync_action(self, data):
done_statuses = self._get_done_statuses()
c_from = data["changelog"]["from"]
c_to = data["changelog"]["to"]
return ResolveSyncAction.from_resolve_unresolve(
should_resolve=c_to in done_statuses and c_from not in done_statuses,
should_unresolve=c_from in done_statuses and c_to not in done_statuses,
)
错误字段映射:Jira 的错误响应可能不直接包含字段 ID,需要手动映射:
CUSTOM_ERROR_MESSAGE_MATCHERS = [
(re.compile("Team with id '.*' not found.$"), "Team Field"),
(re.compile(r"Issue does not exist or you do not have permission..."), "Issue"),
]
17.5.4 Vercel 集成 —— Serverless 部署集成
Vercel 集成(src/sentry/integrations/vercel/)展示了几个独特模式:
外部安装(can_add=False, can_add_externally=True):安装只能从 Vercel Marketplace 发起,Vercel 执行 OAuth 授权后将 code 通过重定向传递回 Sentry:
class VercelIntegrationProvider(IntegrationProvider):
can_add = False
can_add_externally = True
class VercelOAuthApiStep(OAuth2ApiStep):
def extract_code(self, validated_data, pipeline):
# code 是 marketplace 跳转时注入的初始数据
return cast(str, pipeline.fetch_state("code"))
Internal Integration 创建:Vercel 安装完成后,自动创建一个 Sentry 内部应用来生成 Release Token:
def post_install(self, integration, organization, *, extra):
sentry_app = SentryAppCreator(
name="Vercel Internal Integration",
author="Auto-generated by Sentry",
organization_id=organization.id,
is_internal=True,
verify_install=False,
overview=internal_integration_overview,
scopes=["org:ci"],
).run(user=user)
SentryAppInstallationForProvider.objects.create(
sentry_app_installation=sentry_app_installation,
organization_id=organization.id,
provider="vercel",
)
环境变量注入:通过 VercelEnvVarMapBuilder 构建器模式向 Vercel 项目注入 SENTRY_ORG、SENTRY_PROJECT、SENTRY_DSN、SENTRY_AUTH_TOKEN 等环境变量。
17.5.5 Microsoft Teams 集成 —— 外部安装流程
MS Teams 集成(src/sentry/integrations/msteams/)展示了典型的”完全外部发起”安装流程:
class MsTeamsIntegrationProvider(IntegrationProvider):
can_add = False
can_add_externally = True
安装数据通过签名的 signed_params 传递:
class MsTeamsInstallParams(TypedDict):
external_id: str
external_name: str
service_url: str
user_id: str
conversation_id: str
tenant_id: str
installation_type: str
# 验证并解析
class MsTeamsInitialDataSerializer(CamelSnakeSerializer):
signed_params = CharField(required=True)
def validate(self, attrs):
try:
return unsign(attrs["signed_params"],
max_age=INSTALL_EXPIRATION_TIME, salt=SALT)
except SignatureExpired:
raise ValidationError("Installation link expired")
安装完成后通过 Teams Bot 发送欢迎卡片:
def post_install(self, integration, organization, *, extra):
client = MsTeamsClient(integration)
card = (build_team_installation_confirmation_message(organization)
if integration.metadata["installation_type"] == "team"
else build_personal_installation_confirmation_message())
client.send_card(extra["conversation_id"], card)
17.5.6 GitLab 集成 —— 多事件 Webhook 处理
GitLab 集成(src/sentry/integrations/gitlab/)的 Webhook 端点展示了事件驱动的设计模式:
@cell_silo_endpoint
class GitlabWebhookEndpoint(Endpoint):
_handlers = {
"Push Hook": PushEventWebhook,
"Merge Request Hook": MergeEventWebhook,
"Note Hook": NoteEventWebhook,
"Issue Hook": IssuesEventWebhook,
}
def post(self, request):
# 1. 从 HTTP_X_GITLAB_TOKEN 提取 external_id 和 secret
# 2. 查找对应的 Integration
# 3. 验证 webhook secret
# 4. 根据 HTTP_X_GITLAB_EVENT 选择 handler
# 5. 对所有安装了该集成的组织执行 handler
for install in installs:
handler = self._handlers[request.META["HTTP_X_GITLAB_EVENT"]]()
handler(event, integration=integration, organization=organization)
return HttpResponse(status=204)
GitLab 的 webhook token 使用了特殊格式 instance:group_path:secret,在 token 中嵌入了路由信息:
def get_gitlab_external_id(request, extra):
token = request.META["HTTP_X_GITLAB_TOKEN"]
# e.g. "example.gitlab.com:group-x:webhook_secret"
instance, group_path, secret = token.split(":")
external_id = f"{instance}:{group_path}"
return (external_id, secret)
17.5.7 Bitbucket 与 Azure DevOps
Bitbucket(src/sentry/integrations/bitbucket/)的 Webhook 端点展示了多层安全验证:
- IP 白名单:验证请求来源 IP 是否在 Bitbucket 的 IP 范围内
- 签名验证:使用 HMAC-SHA256 验证
X-Hub-Signature - 仓库归属:验证仓库确实属于该组织
class BitbucketWebhookEndpoint(Endpoint):
def post(self, request, organization_id):
# IP 范围检查
ip = ipaddress.ip_address(request.META["REMOTE_ADDR"])
valid_ip = any(ip in ip_range for ip_range in BITBUCKET_IP_RANGES)
# 签名验证
secret = integration.metadata["webhook_secret"]
if not is_valid_signature(request.body, secret, signature):
raise WebhookInvalidSignatureException()
Azure DevOps (VSTS)(src/sentry/integrations/vsts/)展示了 OAuth 2.0 授权码流程的复杂性,包括 Access Token 刷新、账号选择等。
17.6 Webhook 处理机制
17.6.1 Webhook 端点注册
Sentry 中每种集成的 Webhook 都有独立的 URL 端点。以 Jira 为例:
# URL 配置 (jira/urls.py)
urlpatterns = [
path("issue-updated/", JiraIssueUpdatedWebhook.as_view(), name="sentry-extensions-jira-issue-updated"),
path("installed/", JiraInstalledWebhook.as_view(), name="sentry-extensions-jira-installed"),
path("uninstalled/", JiraUninstalledWebhook.as_view(), name="sentry-extensions-jira-uninstalled"),
]
每个 Webhook 端点继承自 JiraWebhookBase,后者继承自 Endpoint 并提供:
- CSRF 豁免:
@csrf_exempt装饰器 - 认证豁免:
authentication_classes = () - 权限豁免:
permission_classes = () - 错误处理:区分 Jira API 错误、数据库错误和未知错误
17.6.2 签名验证与安全
不同平台使用不同的签名机制:
| 平台 | 签名方式 | 示例 |
|---|---|---|
| GitHub | HMAC-SHA256, X-Hub-Signature-256 |
sha256=abc123... |
| Bitbucket | HMAC-SHA256, X-Hub-Signature |
sha256=abc123... |
| GitLab | 自定义 token, X-Gitlab-Token |
instance:group:secret |
| Jira | Atlassian Connect JWT | qsh query-string hash |
| Slack | Signing Secret, X-Slack-Signature |
v0=abc123... |
17.6.3 事件分发与去重
GitLab 的 Webhook 展示了事件分发模式。GitlabWebhook 基类支持注册多个处理器:
class GitlabWebhook(SCMWebhook, ABC):
WEBHOOK_EVENT_PROCESSORS: tuple[WebhookProcessor, ...] = ()
def _handle(self, integration, event, organization, repo, **kwargs):
for processor in self.WEBHOOK_EVENT_PROCESSORS:
try:
processor(event=event, integration=integration,
organization=organization, repo=repo, **kwargs)
except Exception as e:
sentry_sdk.capture_exception(e)
continue # 一个处理器失败不影响其他
Merge Request 事件的处理器链:
class MergeEventWebhook(GitlabWebhook):
WEBHOOK_EVENT_PROCESSORS = (
track_gitlab_contributor_seat_processor, # 追踪贡献者席位
track_gitlab_contributor_action_processor, # 追踪贡献者操作
handle_merge_request_event, # 处理 MR 事件
)
17.6.4 Webhook 上下文与监控
Sentry 为 Webhook 提供了丰富的监控基础设施:
# IntegrationWebhookEvent 用于记录事件指标
with IntegrationWebhookEvent(
interaction_type=event_handler.event_type,
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
provider_key=event_handler.provider,
).capture():
event_handler(event, repo=repo, organization=organization)
# webhook_viewer_context 用于关联日志
with webhook_viewer_context(organization.id):
...
17.7 Sentry App 开发
17.7.1 Sentry App 架构概览
Sentry App 是面向第三方开发者的公开集成平台,与内置 Integration 有以下关键区别:
| 维度 | Integration | Sentry App |
|---|---|---|
| 代码位置 | 核心代码库内 | 外部独立服务 |
| 安装方式 | 组织设置页 + 管道 | OAuth 2.0 授权 |
| API 访问 | 内部 API(无限制) | 公开 REST API(Scope 控制) |
| 数据存储 | 自定义模型 | 平台管理的模型 |
| UI 扩展 | 有限 | Issue 面板、设置页、Stacktrace 链接 |
| 事件订阅 | 自定义 Webhook | 统一 Webhook 分发 |
核心模型关系:
SentryApp (应用定义)
└── SentryAppComponent (UI 组件:issue-link, stacktrace-link, alert-rule-action)
└── ServiceHook (Webhook 订阅)
└── ApiToken (API 访问令牌)
SentryAppInstallation (安装实例)
└── 关联 Organization
└── SentryAppInstallationToken (安装级令牌)
17.7.2 UI 组件扩展(SentryAppComponent)
Sentry App 可以注册三种 UI 组件(src/sentry/sentry_apps/components.py):
| 组件类型 | 说明 | 出现位置 |
|---|---|---|
issue-link |
外部 Issue 链接与创建 | Issue 详情页 |
stacktrace-link |
堆栈帧链接到外部服务 | 异常堆栈面板 |
alert-rule-action |
自定义告警动作 | 告警规则配置页 |
组件定义示例(JSON Schema):
{
"type": "issue-link",
"schema": {
"link": {
"uri": "/issues/link",
"required_fields": [
{"name": "externalIssue", "label": "Issue", "type": "select", "uri": "/issues/search"}
]
},
"create": {
"uri": "/issues/create",
"required_fields": [
{"name": "title", "label": "Title", "type": "text"},
{"name": "project", "label": "Project", "type": "select", "uri": "/projects"}
]
}
}
}
SentryAppComponentPreparer 负责在运行时准备组件数据。以 stacktrace-link 为例:
def _prepare_stacktrace_link(self):
schema = self.component.app_schema
uri = schema.get("uri")
# 将相对 URI 拼接到 webhook_url
urlparts = list(urlparse(self.install.sentry_app.webhook_url))
urlparts[2] = str(uri)
# 注入 installationId 和 projectSlug
query = {"installationId": self.install.uuid}
if self.project_slug:
query["projectSlug"] = self.project_slug
urlparts[4] = urlencode(query)
schema.update({"url": urlunparse(urlparts)})
17.7.3 Webhook 与事件订阅
Sentry App 通过 ServiceHook 模型订阅事件。可订阅的事件类型包括(src/sentry/sentry_apps/event_types.py):
| 事件类别 | 具体事件 |
|---|---|
issue |
issue.created, issue.resolved, issue.ignored, issue.assigned |
error |
error.created |
comment |
comment.created, comment.updated, comment.deleted |
installation |
installation.created, installation.deleted |
事件可以注册为”回滚事件”(rolled-up),例如订阅 issue 会自动包含 issue.created、issue.resolved 等子事件:
EVENT_EXPANSION = {
"issue": ["issue.created", "issue.resolved", "issue.ignored", "issue.assigned"],
"comment": ["comment.created", "comment.updated", "comment.deleted"],
"error": ["error.created"],
}
HookService(src/sentry/sentry_apps/services/hook/service.py)负责管理 Webhook 的创建和更新:
class HookService(RpcService):
def create_service_hook(self, *, application_id, actor_id, installation_id,
organization_id, project_ids, events, url):
"""为 Sentry App 创建 ServiceHook"""
def update_webhook_and_events(self, *, organization_id, application_id,
webhook_url, events):
"""更新所有安装的 Webhook 配置"""
17.7.4 OAuth 安装流程
Sentry App 的安装遵循标准 OAuth 2.0 授权码流程:
1. 第三方应用 → GET /sentry-apps/{slug}/install/ (Sentry 授权页面)
2. 用户授权 → 重定向到应用注册的 redirect_url?code=xxx&installationId=yyy
3. 应用 → POST /api/0/sentry-app-installations/{uuid}/authorization/
携带 grant_type=authorization_code, code=xxx, client_id, client_secret
4. Sentry → 返回 access_token, refresh_token, token_type=bearer
SentryAppInstallationCreator(src/sentry/sentry_apps/installations.py)处理安装的创建逻辑。
17.7.5 Sentry App 创建与管理(SentryAppCreator)
SentryAppCreator(src/sentry/sentry_apps/logic.py)封装了创建 Sentry App 的完整流程:
class SentryAppCreator:
def __init__(self, *, name, author, organization_id, is_internal=False,
scopes, webhook_url=None, redirect_url=None,
verify_install=True, overview=None, schema=None):
...
def run(self, user) -> SentryApp:
# 1. 创建 ApiApplication (OAuth 客户端)
# 2. 创建 SentryApp
# 3. 创建 SentryAppComponent (如果提供了 schema)
# 4. 创建 ApiToken (如果是 Internal Integration)
# 5. 创建 ServiceHook (如果提供了 webhook_url)
# 6. 自动安装 (如果是 Internal Integration)
17.7.6 Internal Integration
Internal Integration 是一种特殊的 Sentry App,它:
- 仅对创建它的组织可见(
is_internal=True) - 安装过程无需 OAuth 授权(
verify_install=False) - 创建时自动生成长期有效的 API Token
- 主要用于 Vercel 集成等需要 Sentry API 访问的内部工具
17.8 Plugin 体系
17.8.1 Plugin v1 vs v2
Sentry 的 Plugin 体系有两个版本:
| 特性 | Plugin v1 | Plugin v2 |
|---|---|---|
| 引入版本 | Sentry v4 | Sentry v8+ |
| 安装方式 | pip install |
pip 安装 + 注册 |
| 配置方式 | sentry.plugins entry_point |
Django INSTALLED_APPS |
| 数据存储 | 项目级 JSON 配置 | 同 v1 |
| 当前状态 | 基本废弃 | 基本废弃 |
核心区别在于入口机制:v1 通过 Python entry_points 注册,v2 通过 Django INSTALLED_APPS。
17.8.2 Server Plugin 和 JavaScript Plugin
Plugin 按运行位置分为两类:
- Server Plugin:在 Sentry 服务器端运行,可以访问内部 API 和数据库。通过继承
sentry.plugins.base.Plugin实现。 - JavaScript Plugin:在用户浏览器中运行,只能通过公开 API 与 Sentry 交互。通过前端 JavaScript SDK 实现。
当前 Sentry 已不再推荐开发新的 Plugin。Plugin 目录(src/sentry/plugins/)主要保留以下基础结构:
plugins/
base/
__init__.py # 导出 bindings
binding_manager.py # BindingManager
response.py # 响应类型
structs.py # Annotation, Notification
interfaces/ # 接口定义
providers/
__init__.py
base.py # IntegrationRepositoryProvider 基类
integration_repository.py
17.8.3 BindingManager 绑定机制
BindingManager(src/sentry/plugins/base/binding_manager.py)是 Plugin 体系中用于注册和发现能力的机制:
class BindingManager:
BINDINGS = {
"integration-repository.provider": IntegrationRepositoryProviderManager,
}
def __init__(self):
self._bindings = {k: v() for k, v in self.BINDINGS.items()}
def add(self, name, binding, **kwargs):
self._bindings[name].add(binding, **kwargs)
def get(self, name):
return self._bindings[name]
# 全局绑定管理器
bindings = BindingManager()
Integration 通过 setup() 方法注册绑定:
# GitHub 集成在 setup() 中注册 Repository Provider
def setup(self):
from sentry.plugins.base import bindings
bindings.add("integration-repository.provider", GitHubRepositoryProvider, id="integrations:github")
17.8.4 IntegrationRepositoryProvider
IntegrationRepositoryProvider(src/sentry/plugins/providers/integration_repository.py)是连接 Integration 和 Repository 的桥梁:
class IntegrationRepositoryProvider(RepositoryProvider):
def get_installation(self, integration_id, organization_id):
"""通过 integration_id 获取 IntegrationInstallation 实例"""
def get_repository_data(self, organization, config):
"""构建仓库配置数据"""
def build_repository_config(self, organization, data) -> RepositoryConfig:
"""构建 RepositoryConfig"""
return {
"name": data["name"],
"external_id": data["external_id"],
"url": data.get("url"),
"config": data.get("config", {}),
"integration_id": int(data["integration_id"]),
}
def compare_commits(self, repo, start_sha, end_sha):
"""比较两个 commit 之间的提交记录"""
17.8.5 Plugin 与 Integration 的关系
Plugin 和 Integration 不是互斥的 —— Integration 内部使用 Plugin 的 BindingManager 来注册 Repository Provider。这种关系体现为:
IntegrationManager (管理 IntegrationProvider)
└── IntegrationProvider.setup()
└── BindingManager.add("integration-repository.provider", ...)
└── IntegrationRepositoryProvider (处理仓库操作)
17.9 开发一个完整的 Integration —— 示例
17.9.1 需求分析
假设我们要为虚构的项目管理工具 “Tracely” 开发一个集成,需求如下:
- 用户可以在 Sentry 中创建和链接 Tracely 工单
- Tracely 工单状态变化时同步到 Sentry(关闭工单 = 解决 Issue)
- 支持从 Tracely 的 API 同步用户名和邮件
17.9.2 目录结构与代码组织
推荐的文件组织方式(参照 GitHub 集成):
src/sentry/integrations/tracely/
__init__.py # 模块入口
integration.py # IntegrationProvider + IntegrationInstallation
client.py # Tracely API 客户端
issues.py # Issue 相关逻辑
urls.py # Webhook URL 配置
webhooks.py # Webhook 处理
tasks.py # 异步任务
17.9.3 定义元数据与功能描述
# integration.py
from sentry.integrations.base import (
FeatureDescription,
IntegrationFeatures,
IntegrationMetadata,
)
from django.utils.translation import gettext_lazy as _
DESCRIPTION = """
Connect your Sentry organization to your Tracely workspace. Create and link
Tracely tickets directly from Sentry issues, and synchronize ticket statuses
bidirectionally.
"""
FEATURES = [
FeatureDescription(
"""
Create and link Sentry issue groups directly to a Tracely ticket,
providing a quick way to jump from a Sentry bug to tracked ticket.
""",
IntegrationFeatures.ISSUE_BASIC,
),
FeatureDescription(
"""
Automatically synchronize statuses to and from Tracely. When a ticket
is marked closed in Tracely, resolve the linked Sentry issue.
""",
IntegrationFeatures.ISSUE_SYNC,
),
]
metadata = IntegrationMetadata(
description=DESCRIPTION.strip(),
features=FEATURES,
author="Your Company",
noun=_("Workspace"),
issue_url="https://github.com/your-org/sentry-tracely/issues/new",
source_url="https://github.com/your-org/sentry-tracely",
aspects={},
)
17.9.4 实现 IntegrationInstallation
from sentry.integrations.base import IntegrationInstallation
from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction
from sentry.integrations.models.external_issue import ExternalIssue
from sentry.shared_integrations.exceptions import IntegrationError
class TracelyIntegration(IssueSyncIntegration):
# IssueSyncIntegration 配置键
comment_key = "sync_comments"
outbound_status_key = "sync_status_forward"
inbound_status_key = "sync_status_reverse"
outbound_assignee_key = "sync_forward_assignment"
inbound_assignee_key = "sync_reverse_assignment"
def get_client(self):
"""返回 Tracely API 客户端"""
return TracelyApiClient(
base_url=self.model.metadata["base_url"],
api_key=self.model.metadata["api_key"],
)
def get_issue_url(self, key: str) -> str:
return f"{self.model.metadata['base_url']}/tickets/{key}"
def get_issue(self, issue_id, **kwargs):
client = self.get_client()
try:
ticket = client.get_ticket(issue_id)
except ApiError as e:
self.raise_error(e)
return {
"key": ticket["id"],
"title": ticket["title"],
"description": ticket["description"],
}
def create_issue(self, data, **kwargs):
client = self.get_client()
try:
ticket = client.create_ticket(
title=data["title"],
description=data.get("description", ""),
project=data.get("project"),
)
except ApiError as e:
self.raise_error(e)
return {
"key": ticket["id"],
"title": ticket["title"],
"description": ticket["description"],
}
def search_issues(self, query, **kwargs):
client = self.get_client()
results = client.search_tickets(query)
return [{"label": f"#{t['id']} {t['title']}", "value": t["id"]}
for t in results]
def sync_assignee_outbound(self, external_issue, user, assign=True, **kwargs):
client = self.get_client()
tracely_user_id = None
if assign and user:
tracely_user_id = self._find_tracely_user(user)
client.assign_ticket(external_issue.key, tracely_user_id)
def sync_status_outbound(self, external_issue, is_resolved, project_id):
client = self.get_client()
new_status = "closed" if is_resolved else "open"
client.update_ticket_status(external_issue.key, new_status)
def get_resolve_sync_action(self, data):
status = data.get("status", "")
return ResolveSyncAction.from_resolve_unresolve(
should_resolve=status == "closed",
should_unresolve=status == "reopened",
)
def get_organization_config(self):
return [
{
"name": self.outbound_status_key,
"type": "choice_mapper",
"label": "Sync Sentry Status to Tracely",
"help": "When a Sentry issue changes status...",
"addButtonText": "Add Tracely Project",
"addDropdown": {
"emptyMessage": "All projects configured",
"items": [], # 动态填充
},
"mappedSelectors": {
"on_resolve": {"choices": [("closed", "Closed")]},
"on_unresolve": {"choices": [("open", "Open")]},
},
},
{"name": self.comment_key, "type": "boolean",
"label": "Sync Sentry Comments to Tracely"},
]
def create_comment(self, issue_id, user_id, group_note):
client = self.get_client()
return client.create_comment(
issue_id, group_note.data["text"],
)
def get_create_issue_config(self, group, user, **kwargs):
fields = super().get_create_issue_config(group, user, **kwargs)
# 添加项目选择字段
client = self.get_client()
projects = client.get_projects()
fields.insert(0, {
"name": "project",
"label": "Tracely Project",
"type": "select",
"choices": [(p["id"], p["name"]) for p in projects],
"required": True,
})
return fields
17.9.5 实现 API 客户端
# client.py
from sentry.shared_integrations.client.base import BaseApiClient
from sentry.shared_integrations.exceptions import ApiError
class TracelyApiClient(BaseApiClient):
base_url: str
api_key: str
def __init__(self, base_url: str, api_key: str):
super().__init__()
self.base_url = base_url.rstrip("/")
self.api_key = api_key
def _get_headers(self):
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
def get_ticket(self, ticket_id: str) -> dict:
return self.get(f"/api/v1/tickets/{ticket_id}")
def create_ticket(self, title: str, description: str, project: str) -> dict:
return self.post("/api/v1/tickets", data={
"title": title,
"description": description,
"project": project,
})
def search_tickets(self, query: str) -> list[dict]:
return self.get("/api/v1/tickets/search", params={"q": query})
def assign_ticket(self, ticket_id: str, user_id: str | None):
return self.put(f"/api/v1/tickets/{ticket_id}/assignee", data={
"user_id": user_id,
})
def update_ticket_status(self, ticket_id: str, status: str):
return self.put(f"/api/v1/tickets/{ticket_id}/status", data={
"status": status,
})
def create_comment(self, ticket_id: str, body: str):
return self.post(f"/api/v1/tickets/{ticket_id}/comments", data={
"body": body,
})
def get_projects(self) -> list[dict]:
return self.get("/api/v1/projects")
def get_user_by_email(self, email: str) -> dict | None:
users = self.get("/api/v1/users/search", params={"email": email})
return users[0] if users else None
def request(self, method, path, **kwargs):
# 统一错误处理
try:
return self._request(method, f"{self.base_url}{path}", **kwargs)
except Exception as e:
raise ApiError(str(e))
17.9.6 实现安装管道步骤
from sentry.pipeline.types import PipelineStepResult
from sentry.pipeline.views.base import ApiPipelineSteps
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
from rest_framework.fields import CharField
class TracelyConfigSerializer(CamelSnakeSerializer):
base_url = CharField(required=True)
api_key = CharField(required=True)
class TracelyConfigApiStep:
step_name = "config"
def get_step_data(self, pipeline, request):
return {
"fields": [
{"name": "base_url", "label": "Tracely URL",
"type": "text", "required": True,
"placeholder": "https://your-company.tracely.io"},
{"name": "api_key", "label": "API Key",
"type": "password", "required": True},
]
}
def get_serializer_cls(self):
return TracelyConfigSerializer
def handle_post(self, validated_data, pipeline, request):
# 验证 API 连通性
client = TracelyApiClient(
base_url=validated_data["base_url"],
api_key=validated_data["api_key"],
)
try:
client.get_projects()
except ApiError:
return PipelineStepResult.error(
"Unable to connect to Tracely. Please verify your URL and API key."
)
pipeline.bind_state("base_url", validated_data["base_url"])
pipeline.bind_state("api_key", validated_data["api_key"])
return PipelineStepResult.advance()
17.9.7 实现 IntegrationProvider
from sentry.integrations.base import IntegrationProvider, IntegrationData
from sentry.integrations.pipeline import IntegrationPipeline
class TracelyIntegrationProvider(IntegrationProvider):
key = "tracely"
name = "Tracely"
metadata = metadata
integration_cls = TracelyIntegration
features = frozenset([
IntegrationFeatures.ISSUE_BASIC,
IntegrationFeatures.ISSUE_SYNC,
])
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
return [TracelyConfigApiStep()]
def build_integration(self, state) -> IntegrationData:
return {
"external_id": state["base_url"],
"name": state["base_url"].split("//")[1].split(".")[0],
"metadata": {
"base_url": state["base_url"],
"api_key": state["api_key"],
},
}
def setup(self):
"""注册 URL 路由"""
from django.urls import re_path
from .webhooks import TracelyWebhookEndpoint
# 在实际项目中,路由注册通过 URL 配置文件完成
17.9.8 注册与绑定
在实际 Sentry 项目中,注册 Integration 有多种方式。最常见的是在模块的 __init__.py 中调用 register(),由父模块在 Django ready 时自动导入。同时需要在对应目录下的 urls.py 中注册 Webhook URL。
17.9.9 完整示例代码
Sentry 代码库中提供了一个 ExampleIntegration(src/sentry/integrations/example/integration.py),它是理解 Integration 开发的最佳起点。该示例涵盖:
- ExampleIntegration:实现了
RepositoryIntegration、SourceCodeIssueIntegration、IssueSyncIntegration三个 Mixin - ExampleIntegrationProvider:包含
get_pipeline_api_steps()、build_integration()、setup()的基本实现 - ExampleSetupApiStep:演示了管道步骤的完整实现
- ExampleRepositoryProvider:演示了如何为 Integration 提供仓库操作支持
- AliasedIntegration:演示了
_integration_key机制(多个 Provider 共享同一个 Integration 实例) - FeatureFlagIntegration:演示了 Feature Flag 控制的集成
17.10 测试策略
17.10.1 管道步骤测试
管道步骤的测试需要模拟 IntegrationPipeline 和请求上下文:
from sentry.testutils.cases import TestCase
class TracelyConfigApiStepTest(TestCase):
def setUp(self):
self.organization = self.create_organization()
self.pipeline = self.create_integration_pipeline(
organization=self.organization,
provider_key="tracely",
)
def test_get_step_data(self):
step = TracelyConfigApiStep()
data = step.get_step_data(self.pipeline, self.make_request())
assert len(data["fields"]) == 2
assert data["fields"][0]["name"] == "base_url"
def test_handle_post_success(self):
step = TracelyConfigApiStep()
result = step.handle_post(
{"base_url": "https://test.tracely.io", "api_key": "tk_test123"},
self.pipeline,
self.make_request(),
)
assert result.action == "advance"
assert self.pipeline.fetch_state("base_url") == "https://test.tracely.io"
def test_handle_post_api_error(self):
step = TracelyConfigApiStep()
# 使用 mock 模拟 API 失败
result = step.handle_post(
{"base_url": "https://invalid.tracely.io", "api_key": "bad_key"},
self.pipeline,
self.make_request(),
)
assert result.action == "error"
17.10.2 Integration 功能测试
测试 Integration 的功能需要创建完整的数据库记录:
class TracelyIntegrationTest(TestCase):
def setUp(self):
self.organization = self.create_organization()
self.integration = self.create_integration(
organization=self.organization,
provider="tracely",
external_id="test.tracely.io",
metadata={"base_url": "https://test.tracely.io", "api_key": "tk_test"},
)
self.installation = self.integration.get_installation(
self.organization.id,
)
@mock.patch("sentry.integrations.tracely.client.TracelyApiClient.get_ticket")
def test_get_issue(self, mock_get_ticket):
mock_get_ticket.return_value = {
"id": "TCK-123",
"title": "Test Ticket",
"description": "A test ticket",
}
issue = self.installation.get_issue("TCK-123")
assert issue["key"] == "TCK-123"
assert issue["title"] == "Test Ticket"
17.10.3 Webhook 端点测试
Webhook 端点测试需要特别注意签名验证:
class TracelyWebhookEndpointTest(TestCase):
def setUp(self):
self.organization = self.create_organization()
self.integration = self.create_integration(
organization=self.organization,
provider="tracely",
external_id="test.tracely.io",
metadata={"base_url": "https://test.tracely.io",
"webhook_secret": "whsec_test123"},
)
def test_valid_webhook(self):
payload = {
"event": "ticket.closed",
"ticket": {"id": "TCK-123", "status": "closed"},
}
signature = self._compute_signature(payload, "whsec_test123")
response = self.client.post(
reverse("sentry-extensions-tracely-webhook"),
data=json.dumps(payload),
content_type="application/json",
HTTP_X_TRACELY_SIGNATURE=signature,
)
assert response.status_code == 200
def test_invalid_signature(self):
payload = {"event": "ticket.closed"}
response = self.client.post(
reverse("sentry-extensions-tracely-webhook"),
data=json.dumps(payload),
content_type="application/json",
HTTP_X_TRACELY_SIGNATURE="invalid",
)
assert response.status_code == 401
17.10.4 测试工具与 Mock
Sentry 提供了丰富的测试工具(sentry/testutils/):
| 工具 | 用途 |
|---|---|
create_integration() |
创建 Integration + OrganizationIntegration |
create_organization_integration() |
创建 OrganizationIntegration |
create_external_issue() |
创建 ExternalIssue |
Responses 库 |
Mock 外部 HTTP 请求 |
@mock.patch |
Mock Python 对象 |
17.11 发布与上线
17.11.1 Feature Flag 控制
Sentry 使用 Feature Flag 系统控制集成的可见性和功能:
class MyIntegrationProvider(IntegrationProvider):
requires_feature_flag = True # 只有开启了 Flag 的组织才能看到
# 默认 Flag 名: organizations:integrations-{key}
# 自定义 Flag 名:
feature_flag_name = "organizations:my-custom-flag"
功能级别的 Flag 控制(在 IntegrationMetadata.asdict() 中生成):
def feature_flag_name(f: str | None) -> str | None:
if f is not None:
return f"integrations-{f}"
return None
17.11.2 灰度发布策略
推荐的集成发布策略:
- 内部测试:先在 Sentry 内部组织安装和测试
- Staff 限定:通过 Staff 权限限制安装
- Feature Flag 灰度:对特定组织开启
requires_feature_flag - 全量上线:移除 Feature Flag 限制
17.11.3 监控与日志
每个 Integration 应该有自己的 logger:
logger = logging.getLogger("sentry.integrations.tracely")
关键监控指标:
# 安装尝试
metrics.incr("sentry.integrations.installation_attempt",
tags={"integration_name": "tracely"})
# 安装完成
metrics.incr("sentry.integrations.installation_finished",
tags={"integration_name": "tracely"})
# API 错误
metrics.incr("sentry.integrations.tracely.api_error",
tags={"endpoint": "create_ticket", "status_code": "500"})
17.11.4 常见问题排查
| 问题 | 可能原因 | 排查方法 |
|---|---|---|
| 集成未显示在列表 | requires_feature_flag=True 但 Flag 未开启 |
检查组织 Feature Flag |
| 安装管道失败 | build_integration 返回的数据不完整 |
检查 external_id 是否必填 |
| API 调用 401 | Token 过期或权限不足 | 检查 get_client() 的认证逻辑 |
| Webhook 未收到 | URL 路由未注册或签名验证失败 | 检查 urls.py 和签名算法 |
| 状态不同步 | org_integration.config 中的配置键不匹配 |
对比 get_organization_config 返回的键名 |
本章涵盖了 Sentry 集成体系从架构设计到具体实现的完整知识。理解 IntegrationProvider、IntegrationInstallation 和 Pipeline 三者的关系是核心,而 Mixin 体系提供了可组合的功能模块。建议从 ExampleIntegration 开始动手实践,逐步深入理解各平台的实现细节。