znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第十七章:集成(Integrations)开发指南


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 功能矩阵

IntegrationFeaturessrc/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 通过 IntegrationManagersrc/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 基类

IntegrationProvidersrc/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_addcan_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)

安装流程由 IntegrationPipelinesrc/sentry/integrations/pipeline.py:127)驱动,它继承自通用的 Pipeline 基类。安装过程分为以下阶段:

初始化 → 验证权限 → 执行管道步骤 → finish_pipeline → post_install

initialize_integration_pipeline() 函数在管道启动时执行以下预检查(pipeline.py:60-103):

  1. Feature Flag 检查:验证 requires_feature_flag 对应的开关是否启用
  2. 功能特性检查:至少一个功能 Flag 可用
  3. 安装能力检查can_addcan_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 实例构造与初始化

IntegrationInstallationsrc/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 —— 问题基础集成

IssueBasicIntegrationsrc/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 —— 仓库集成

RepositoryIntegrationsrc/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 —— 通知基础混入

NotifyBasicMixinsrc/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/)展示了消息型集成的设计模式。它同时实现了 IntegrationInstallationIntegrationNotificationClient

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_ORGSENTRY_PROJECTSENTRY_DSNSENTRY_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

Bitbucketsrc/sentry/integrations/bitbucket/)的 Webhook 端点展示了多层安全验证:

  1. IP 白名单:验证请求来源 IP 是否在 Bitbucket 的 IP 范围内
  2. 签名验证:使用 HMAC-SHA256 验证 X-Hub-Signature
  3. 仓库归属:验证仓库确实属于该组织
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.createdissue.resolved 等子事件:

EVENT_EXPANSION = {
    "issue": ["issue.created", "issue.resolved", "issue.ignored", "issue.assigned"],
    "comment": ["comment.created", "comment.updated", "comment.deleted"],
    "error": ["error.created"],
}

HookServicesrc/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

SentryAppInstallationCreatorsrc/sentry/sentry_apps/installations.py)处理安装的创建逻辑。

17.7.5 Sentry App 创建与管理(SentryAppCreator)

SentryAppCreatorsrc/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 绑定机制

BindingManagersrc/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

IntegrationRepositoryProvidersrc/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” 开发一个集成,需求如下:

  1. 用户可以在 Sentry 中创建和链接 Tracely 工单
  2. Tracely 工单状态变化时同步到 Sentry(关闭工单 = 解决 Issue)
  3. 支持从 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 代码库中提供了一个 ExampleIntegrationsrc/sentry/integrations/example/integration.py),它是理解 Integration 开发的最佳起点。该示例涵盖:

  • ExampleIntegration:实现了 RepositoryIntegrationSourceCodeIssueIntegrationIssueSyncIntegration 三个 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 灰度发布策略

推荐的集成发布策略:

  1. 内部测试:先在 Sentry 内部组织安装和测试
  2. Staff 限定:通过 Staff 权限限制安装
  3. Feature Flag 灰度:对特定组织开启 requires_feature_flag
  4. 全量上线:移除 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 开始动手实践,逐步深入理解各平台的实现细节。