This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12451-0f8913a8c3f30dc5bb8cab07bda91365a8811cb5
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 719c3e2b311fa6a050749984a43e6a837b9f802f
Author: Goutam Adwant <[email protected]>
AuthorDate: Sun Oct 11 09:46:08 2026 +0000

    [Feature][Connector-V2] Add Sentry project error-events source (#12451)
    
    Signed-off-by: Goutam Adwant <[email protected]>
---
 .github/workflows/labeler/label-scope-conf.yml     |   4 +-
 docs/en/connectors/source/Sentry.md                | 129 ++++
 docs/zh/connectors/source/Sentry.md                | 124 ++++
 plugin-mapping.properties                          |   1 +
 seatunnel-connectors-v2/connector-sentry/pom.xml   |  15 +
 .../seatunnel/sentry/source/SentryClient.java      | 270 ++++++++
 .../seatunnel/sentry/source/SentryPage.java        | 171 +++++
 .../seatunnel/sentry/source/SentrySource.java      |  99 +++
 .../sentry/source/SentrySourceConfig.java          | 177 ++++++
 .../sentry/source/SentrySourceFactory.java         |  70 +++
 .../sentry/source/SentrySourceOptions.java         |  96 +++
 .../sentry/source/SentrySourceReader.java          |  97 +++
 .../seatunnel/sentry/SentryFactoryTest.java        |  15 +
 .../seatunnel/sentry/source/SentrySourceTest.java  | 685 +++++++++++++++++++++
 seatunnel-dist/release-docs/LICENSE                |   4 +
 seatunnel-dist/release-docs/NOTICE                 |  16 +
 .../connector-sentry-e2e/pom.xml                   |  52 ++
 .../e2e/connector/sentry/SentryFixtureTest.java    |  37 ++
 .../seatunnel/e2e/connector/sentry/SentryIT.java   | 130 ++++
 .../e2e/connector/sentry/SentryMaskingTest.java    | 101 +++
 .../src/test/resources/logback-test.xml            |  27 +
 .../src/test/resources/sentry_to_assert.conf       |  53 ++
 seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml   |   1 +
 23 files changed, 2373 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/labeler/label-scope-conf.yml 
b/.github/workflows/labeler/label-scope-conf.yml
index 8a86ab89fd..4687fe725f 100644
--- a/.github/workflows/labeler/label-scope-conf.yml
+++ b/.github/workflows/labeler/label-scope-conf.yml
@@ -363,7 +363,9 @@ selectdb-cloud:
 sentry:
   - all:
       - changed-files:
-          - any-glob-to-any-file: seatunnel-connectors-v2/connector-sentry/**
+          - any-glob-to-any-file:
+              - seatunnel-connectors-v2/connector-sentry/**
+              - 
seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/**
           - all-globs-to-all-files: 
'!seatunnel-connectors-v2/connector-!(sentry)/**'
 socket:
   - all:
diff --git a/docs/en/connectors/source/Sentry.md 
b/docs/en/connectors/source/Sentry.md
new file mode 100644
index 0000000000..c4fd461d9c
--- /dev/null
+++ b/docs/en/connectors/source/Sentry.md
@@ -0,0 +1,129 @@
+import ChangeLog from '../changelog/connector-sentry.md';
+
+# Sentry
+
+> Sentry project error-events source.
+
+## Description
+
+Reads one project's error events using `GET 
/api/0/projects/{organization}/{project}/events/`.
+Use this source to analyze error events in an external warehouse. The existing 
Sentry sink and its DSN authentication are unchanged.
+
+## Key Features
+
+- [x] Batch
+- [ ] Streaming
+- [ ] Exactly-once
+
+Only BATCH mode and parallelism 1 are supported. The source reads an explicit 
time window.
+The engine-injected failure policy must be `FAIL_FAST`; continuing other 
tables is not supported by this single-project source.
+It does not discover projects, read traces or attachments, or create an 
immutable snapshot.
+
+## Prerequisites
+
+Create a bearer token with `project:read` access to the chosen project. A sink 
DSN cannot read events.
+For a regional or self-hosted deployment, set its HTTPS origin in 
`api_base_url`. Redirects are rejected; configure the final API origin directly.
+TLS certificate verification remains enabled. HTTP is supported only for 
fixtures with `mock_mode=true` and the literal dummy `token="mock-token"`.
+
+See the [events 
API](https://docs.sentry.io/api/events/list-a-projects-error-events/) and
+[pagination contract](https://docs.sentry.io/api/pagination/).
+
+## Reading and Recovery Contract
+
+Provide absolute RFC3339 `start_time` and `end_time` with an offset, and start 
strictly before end.
+Both are normalized to UTC and sent on every page. The source sends 
`full=false` and `sample=false`;
+`content` contains the returned summary event, not a full stack trace. 
`page_size` maps to Sentry's `per_page` parameter.
+
+Pagination follows only the `rel="next"` link whose `results="true"`; a next 
link with `results="false"` completes the read.
+Only its cursor is copied into the next request. The configured origin, 
endpoint and time bounds are never replaced by the server's link.
+Missing/malformed pagination, repeated cursors, invalid rows, duplicate JSON 
object keys, oversized responses and exceeding `max_pages` fail the job instead 
of silently truncating it.
+
+HTTP 429, 500, 502, 503, 504 and transport failures are retried within 
`max_retries`.
+`Retry-After` seconds and HTTP dates are respected. Delays exceeding 60 
seconds fail so the job can be retried later; the connector never retries 
earlier than the server requests.
+Authentication failures and redirects are not retried. Closing the reader 
aborts in-flight requests and wakes retry waits.
+The timeout bounds HTTP I/O and schedules a per-attempt abort. Synchronous 
operating-system DNS resolution, JSON parsing and retry waits are not covered 
by a strict total job deadline.
+
+Recovery replays the entire configured window; cursors are not durable 
snapshot offsets and are not checkpointed.
+The reader holds the checkpoint lock during the bounded read, so use 
manageable windows and suitable checkpoint timeouts.
+Rows already written to a nontransactional sink can be duplicated after a 
failed attempt. Configure downstream deduplication if needed.
+Sentry retention, delayed ingestion, event removal and changes while 
paginating can affect results. No complete-history, gap-free adjacent-window or 
snapshot-consistency guarantee is made.
+
+## Output Schema
+
+The schema is fixed; custom `schema` is not supported. All fields use STRING 
to preserve Sentry identifiers and timestamp representations.
+
+| Field | Nullable | Meaning |
+| --- | --- | --- |
+| event_id | no | `eventID` from the response |
+| group_id | yes | `groupID` |
+| project_id | yes | `projectID` |
+| date_created | yes | `dateCreated`, unchanged |
+| title | yes | Event title |
+| message | yes | Event message |
+| platform | yes | Event platform |
+| content | no | Complete returned event object serialized as JSON |
+
+Missing optional fields become null. Unexpected types fail. JSON numeric 
values retain decimal precision, but `content` is not byte-identical to the 
HTTP response.
+Event content can contain personal data: use access-controlled sinks and avoid 
logging rows.
+The `token` option is masked by SeaTunnel's default parsed-configuration 
logging rules.
+
+## Options
+
+| Name | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| token | STRING | yes | - | Bearer token with project read access |
+| organization | STRING | yes | - | Organization ID or slug |
+| project | STRING | yes | - | Project ID or slug |
+| start_time | STRING | yes | - | Absolute RFC3339 start |
+| end_time | STRING | yes | - | Absolute RFC3339 end |
+| api_base_url | STRING | no | https://sentry.io | HTTPS origin without a 
path, query, fragment or trailing slash |
+| page_size | INT | no | 100 | Events requested per page, 1–100 |
+| max_pages | INT | no | 10000 | Page safety limit, 1–100000; fails when 
exceeded |
+| max_retries | INT | no | 3 | Additional transient attempts, 0–5 |
+| retry_delay_ms | INT | no | 1000 | Minimum retry delay, 1–60000 ms |
+| request_timeout_ms | INT | no | 30000 | Per-attempt HTTP connection/socket 
timeout and abort deadline, 1–120000 ms |
+| max_response_bytes | INT | no | 8388608 | Uncompressed body limit, 
1024–16777216 bytes |
+| mock_mode | BOOLEAN | no | false | Permit an HTTP fixture with dummy token 
only |
+
+Source [common options](../common-options/source-common-options.md) such as 
`plugin_output` remain available.
+Project names accept letters, digits, underscores and hyphens. `query`, 
`full`, `sample`, `dsn` and a custom schema are not source options.
+
+## Example
+
+Set the `SENTRY_TOKEN` and `WAREHOUSE_PASSWORD` environment variables before 
starting the job. The unquoted HOCON substitutions below resolve their values 
when the configuration is loaded.
+
+```hocon
+env {
+  parallelism = 1
+  job.mode = "BATCH"
+}
+source {
+  Sentry {
+    plugin_output = "events"
+    token = ${SENTRY_TOKEN}
+    organization = "example"
+    project = "backend"
+    start_time = "2026-01-01T00:00:00Z"
+    end_time = "2026-01-02T00:00:00Z"
+  }
+}
+sink {
+  Jdbc {
+    plugin_input = "events"
+    url = "jdbc:postgresql://warehouse:5432/analytics"
+    driver = "org.postgresql.Driver"
+    user = "writer"
+    password = ${WAREHOUSE_PASSWORD}
+    database = "analytics"
+    table = "sentry_events"
+    generate_sink_sql = true
+  }
+}
+```
+
+Create the destination table with the eight STRING-compatible columns above 
and install its JDBC driver.
+The integration test runs an engine job against a deterministic Sentry API 
fixture; it does not establish compatibility with every Sentry cloud or 
self-hosted release.
+
+## Changelog
+
+<ChangeLog />
diff --git a/docs/zh/connectors/source/Sentry.md 
b/docs/zh/connectors/source/Sentry.md
new file mode 100644
index 0000000000..6d98355c04
--- /dev/null
+++ b/docs/zh/connectors/source/Sentry.md
@@ -0,0 +1,124 @@
+import ChangeLog from '../changelog/connector-sentry.md';
+
+# Sentry
+
+> Sentry 项目错误事件 Source 连接器。
+
+## 描述
+
+通过 `GET /api/0/projects/{organization}/{project}/events/` 
读取单个项目的错误事件,用于外部数据仓库分析。现有 Sentry Sink 和 DSN 认证保持不变。
+
+## 主要特性
+
+- [x] 批处理
+- [ ] 流处理
+- [ ] 精确一次
+
+仅支持 BATCH 模式和并行度 1,读取显式指定的时间范围。不支持项目发现、追踪、附件或一致性快照。
+引擎注入的失败策略必须为 `FAIL_FAST`,单项目 Source 不支持继续处理其他表的策略。
+
+## 前置条件
+
+使用具有目标项目 `project:read` 权限的 Bearer Token,Sink DSN 不能用于读取事件。
+区域服务或自托管部署通过 `api_base_url` 指定 HTTPS 源站,不能包含路径、查询参数、片段或末尾斜杠。
+不跟随重定向,请直接配置最终 API 地址。默认验证 TLS 证书。
+仅在 `mock_mode=true` 且 `token="mock-token"` 时允许 HTTP 测试服务,不得使用真实凭据。
+
+参考 [事件 API](https://docs.sentry.io/api/events/list-a-projects-error-events/) 和 
[分页协议](https://docs.sentry.io/api/pagination/)。
+
+## 读取与恢复语义
+
+`start_time` 和 `end_time` 必须是含时区偏移的绝对 RFC3339 时间,且开始早于结束。每页请求均携带规范化为 UTC 的时间范围。
+固定发送 `full=false` 和 `sample=false`;`content` 是 API 返回的摘要事件,不包含完整堆栈。 
`page_size` 对应 `per_page`。
+
+仅在 Link 响应头中 `rel="next"` 且 `results="true"` 时读取下一页,`results="false"` 表示结束。
+只提取游标,下一页始终使用已配置的源站、路径和时间范围。
+缺失或非法分页头、循环游标、非法记录、重复 JSON 键、响应超限或超过 `max_pages` 均使作业失败,不会静默截断。
+
+HTTP 429、500、502、503、504 和传输失败会进行有界重试。支持以秒数或 HTTP 日期表示的 `Retry-After`。
+超过 60 秒的服务端等待要求会使作业失败,留待稍后重试,不会提前请求。认证失败和重定向不重试。
+关闭 Reader 会中止正在执行的请求并唤醒重试等待。
+超时参数控制 HTTP I/O 并为每次尝试安排中止。操作系统同步 DNS 解析、JSON 解析和重试等待不受严格的作业总时限约束。
+
+恢复时重新读取整个时间范围,不对远端游标做检查点恢复。读取期间持有检查点锁,因此应选择合理时间范围和检查点超时。
+失败前已写入非事务 Sink 的记录可能在重试后重复,必要时在下游去重。
+数据保留策略、延迟写入、事件删除及分页期间数据变更可能影响结果,不保证完整历史、相邻范围无遗漏或快照一致性。
+
+## 输出结构
+
+固定结构,不支持自定义 `schema`。所有列均为 STRING,以保留标识符和时间戳表达。
+
+| 列 | 可为空 | 含义 |
+| --- | --- | --- |
+| event_id | 否 | API 的 `eventID` |
+| group_id | 是 | `groupID` |
+| project_id | 是 | `projectID` |
+| date_created | 是 | 原始 `dateCreated` |
+| title | 是 | 事件标题 |
+| message | 是 | 事件消息 |
+| platform | 是 | 事件平台 |
+| content | 否 | 完整返回事件对象序列化后的 JSON |
+
+缺少可选字段时输出 null,类型不符则失败。JSON 数值保留十进制精度,但 `content` 不保证与响应逐字节一致。
+事件可能包含个人数据,请限制目标存储访问并避免记录行内容。默认配置日志脱敏规则会隐藏 `token`。
+
+## 参数
+
+| 参数 | 类型 | 必填 | 默认值 | 描述 |
+| --- | --- | --- | --- | --- |
+| token | STRING | 是 | - | 具有项目读取权限的 Bearer Token |
+| organization | STRING | 是 | - | 组织 ID 或名称 |
+| project | STRING | 是 | - | 项目 ID 或名称 |
+| start_time | STRING | 是 | - | 绝对 RFC3339 开始时间 |
+| end_time | STRING | 是 | - | 绝对 RFC3339 结束时间 |
+| api_base_url | STRING | 否 | https://sentry.io | HTTPS 源站,不含路径及末尾斜杠 |
+| page_size | INT | 否 | 100 | 每页请求记录数,1–100 |
+| max_pages | INT | 否 | 10000 | 页数上限,1–100000;超限失败 |
+| max_retries | INT | 否 | 3 | 额外重试次数,0–5 |
+| retry_delay_ms | INT | 否 | 1000 | 最小重试间隔,1–60000 毫秒 |
+| request_timeout_ms | INT | 否 | 30000 | 每次 HTTP 连接/读取超时及中止期限,1–120000 毫秒 |
+| max_response_bytes | INT | 否 | 8388608 | 解压后响应字节上限,1024–16777216 |
+| mock_mode | BOOLEAN | 否 | false | 仅允许使用假 Token 的 HTTP 测试服务 |
+
+支持 `plugin_output` 等 [Source 通用参数](../common-options/source-common-options.md)。
+组织和项目名称接受字母、数字、下划线和连字符。Source 不支持 `query`、`full`、`sample`、`dsn` 或自定义 schema。
+
+## 示例
+
+运行前请设置 `SENTRY_TOKEN` 和 `WAREHOUSE_PASSWORD` 环境变量。以下未加引号的 HOCON 
替换表达式将在加载配置时解析为对应值。
+
+```hocon
+env {
+  parallelism = 1
+  job.mode = "BATCH"
+}
+source {
+  Sentry {
+    plugin_output = "events"
+    token = ${SENTRY_TOKEN}
+    organization = "example"
+    project = "backend"
+    start_time = "2026-01-01T00:00:00Z"
+    end_time = "2026-01-02T00:00:00Z"
+  }
+}
+sink {
+  Jdbc {
+    plugin_input = "events"
+    url = "jdbc:postgresql://warehouse:5432/analytics"
+    driver = "org.postgresql.Driver"
+    user = "writer"
+    password = ${WAREHOUSE_PASSWORD}
+    database = "analytics"
+    table = "sentry_events"
+    generate_sink_sql = true
+  }
+}
+```
+
+请按上述八个字段创建兼容 STRING 类型的目标表并安装 JDBC 驱动。
+集成测试使用确定性的 Sentry API 模拟服务运行引擎作业,不代表已验证所有 Sentry 云服务或自托管版本。
+
+## 更新日志
+
+<ChangeLog />
diff --git a/plugin-mapping.properties b/plugin-mapping.properties
index a3fa2cc514..65c23b908c 100644
--- a/plugin-mapping.properties
+++ b/plugin-mapping.properties
@@ -80,6 +80,7 @@ seatunnel.sink.Databend = connector-databend
 seatunnel.source.Databend = connector-databend
 seatunnel.sink.DataHub = connector-datahub
 seatunnel.sink.Sentry = connector-sentry
+seatunnel.source.Sentry = connector-sentry
 seatunnel.source.MongoDB = connector-mongodb
 seatunnel.sink.MongoDB = connector-mongodb
 seatunnel.sink.Couchbase = connector-couchbase
diff --git a/seatunnel-connectors-v2/connector-sentry/pom.xml 
b/seatunnel-connectors-v2/connector-sentry/pom.xml
index eb691200f4..500f17832d 100644
--- a/seatunnel-connectors-v2/connector-sentry/pom.xml
+++ b/seatunnel-connectors-v2/connector-sentry/pom.xml
@@ -34,6 +34,21 @@
     </properties>
 
     <dependencies>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpclient</artifactId>
+            <version>4.5.13</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpcore</artifactId>
+            <version>4.4.16</version>
+        </dependency>
         <dependency>
             <groupId>org.apache.seatunnel</groupId>
             <artifactId>seatunnel-api</artifactId>
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentryClient.java
 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentryClient.java
new file mode 100644
index 0000000000..49f6395a96
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentryClient.java
@@ -0,0 +1,270 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
+import 
org.apache.seatunnel.connectors.seatunnel.sentry.exception.SentryConnectorException;
+
+import org.apache.http.Header;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.config.ConnectionConfig;
+import org.apache.http.config.MessageConstraints;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/** A cancellable, size-bounded client for Sentry's header-based pagination 
contract. */
+final class SentryClient implements AutoCloseable {
+    static SentryConnectorException failure(String message) {
+        return new SentryConnectorException(
+                CommonErrorCodeDeprecated.HTTP_OPERATION_FAILED, message);
+    }
+
+    private final SentrySourceConfig config;
+    private final CloseableHttpClient client;
+    private final ScheduledThreadPoolExecutor timer;
+    private volatile boolean closed;
+    private HttpGet active;
+
+    SentryClient(SentrySourceConfig config) {
+        this.config = config;
+        client =
+                HttpClients.custom()
+                        .setDefaultConnectionConfig(
+                                ConnectionConfig.custom()
+                                        .setMessageConstraints(
+                                                MessageConstraints.custom()
+                                                        
.setMaxLineLength(16384)
+                                                        .setMaxHeaderCount(64)
+                                                        .build())
+                                        .build())
+                        .disableAutomaticRetries()
+                        .disableRedirectHandling()
+                        .disableCookieManagement()
+                        .setDefaultRequestConfig(
+                                RequestConfig.custom()
+                                        .setConnectTimeout(config.timeout)
+                                        .setSocketTimeout(config.timeout)
+                                        
.setConnectionRequestTimeout(config.timeout)
+                                        .build())
+                        .build();
+        timer =
+                new ScheduledThreadPoolExecutor(
+                        1,
+                        r -> {
+                            Thread thread = new Thread(r, 
"sentry-source-request-deadline");
+                            thread.setDaemon(true);
+                            return thread;
+                        });
+        timer.setRemoveOnCancelPolicy(true);
+    }
+
+    SentryPage page(String cursor) throws Exception {
+        for (int attempt = 0; ; attempt++) {
+            checkOpen();
+            URIBuilder uri =
+                    new URIBuilder(config.endpoint)
+                            .addParameter("start", config.start.toString())
+                            .addParameter("end", config.end.toString())
+                            .addParameter("per_page", 
Integer.toString(config.pageSize))
+                            .addParameter("full", "false")
+                            .addParameter("sample", "false");
+            if (cursor != null) {
+                uri.addParameter("cursor", cursor);
+            }
+            HttpGet request = new HttpGet(uri.build());
+            request.setHeader("Authorization", "Bearer " + config.token);
+            request.setHeader("Accept", "application/json");
+            Reply reply;
+            try {
+                reply = execute(request);
+            } catch (IOException e) {
+                retry(attempt, config.retryDelay);
+                continue;
+            }
+            if (transientStatus(reply.status)) {
+                retry(attempt, reply.delay);
+                continue;
+            }
+            if (reply.status != 200) {
+                throw failure(
+                        "Sentry HTTP "
+                                + reply.status
+                                + "; check endpoint, project and project:read 
permission (response withheld)");
+            }
+            return new SentryPage(reply.body, reply.link, config.endpoint);
+        }
+    }
+
+    private Reply execute(HttpGet request) throws IOException {
+        ScheduledFuture<?> deadline;
+        synchronized (this) {
+            checkOpen();
+            active = request;
+            deadline = timer.schedule(request::abort, config.timeout, 
TimeUnit.MILLISECONDS);
+        }
+        CloseableHttpResponse response = null;
+        try {
+            response = client.execute(request);
+            int status = response.getStatusLine().getStatusCode();
+            if (status != 200) {
+                int delay = config.retryDelay;
+                if (transientStatus(status) && 
response.getFirstHeader("Retry-After") != null) {
+                    delay =
+                            retryDelay(
+                                    
response.getFirstHeader("Retry-After").getValue(),
+                                    delay,
+                                    Instant.now());
+                }
+                return new Reply(status, null, null, delay);
+            }
+            Header[] headers = response.getHeaders("Link");
+            StringBuilder links = new StringBuilder();
+            for (Header header : headers) {
+                if (links.length() + header.getValue().length() + 2 > 16384) {
+                    throw failure("Oversized Sentry Link header");
+                }
+                if (links.length() > 0) {
+                    links.append(", ");
+                }
+                links.append(header.getValue());
+            }
+            if (response.getEntity() == null) {
+                throw failure("Missing Sentry response body");
+            }
+            if (response.getEntity().getContentLength() > config.maxBytes) {
+                throw failure("Sentry response exceeds max_response_bytes");
+            }
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            InputStream input = response.getEntity().getContent();
+            byte[] buffer = new byte[8192];
+            int count;
+            while ((count = input.read(buffer)) != -1) {
+                checkOpen();
+                if (bytes.size() > config.maxBytes - count) {
+                    throw failure("Sentry response exceeds 
max_response_bytes");
+                }
+                bytes.write(buffer, 0, count);
+            }
+            return new Reply(
+                    status, bytes.toByteArray(), headers.length == 0 ? null : 
links.toString(), 0);
+        } finally {
+            request.abort();
+            deadline.cancel(false);
+            if (response != null) {
+                try {
+                    response.close();
+                } catch (IOException ignored) {
+                    // Request already aborted; never attach transport details 
containing
+                    // credentials.
+                }
+            }
+            synchronized (this) {
+                active = null;
+            }
+        }
+    }
+
+    static int retryDelay(String value, int minimum, Instant now) {
+        long millis;
+        try {
+            if (value.matches("[0-9]+")) {
+                millis = Math.multiplyExact(Long.parseLong(value), 1000L);
+            } else {
+                millis =
+                        Math.max(
+                                0,
+                                Duration.between(
+                                                now,
+                                                ZonedDateTime.parse(
+                                                                value,
+                                                                
DateTimeFormatter
+                                                                        
.RFC_1123_DATE_TIME)
+                                                        .toInstant())
+                                        .toMillis());
+            }
+            if (millis > 60000) {
+                throw new IllegalArgumentException();
+            }
+        } catch (RuntimeException e) {
+            throw failure("Retry-After exceeds 60 seconds or is invalid; retry 
the job later");
+        }
+        return Math.max(minimum, (int) millis);
+    }
+
+    private static boolean transientStatus(int status) {
+        return status == 429 || status == 500 || status == 502 || status == 
503 || status == 504;
+    }
+
+    private synchronized void retry(int attempt, int delay) throws 
InterruptedException {
+        checkOpen();
+        if (attempt >= config.retries) {
+            throw failure("Sentry HTTP retry budget exhausted (transport 
details withheld)");
+        }
+        long end = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delay);
+        long remaining;
+        while (!closed && (remaining = end - System.nanoTime()) > 0) {
+            TimeUnit.NANOSECONDS.timedWait(this, remaining);
+        }
+        checkOpen();
+    }
+
+    private void checkOpen() {
+        if (closed || Thread.currentThread().isInterrupted()) {
+            throw failure("Sentry request cancelled");
+        }
+    }
+
+    @Override
+    public synchronized void close() throws IOException {
+        closed = true;
+        if (active != null) {
+            active.abort();
+        }
+        notifyAll();
+        timer.shutdownNow();
+        client.close();
+    }
+
+    private static final class Reply {
+        final int status;
+        final byte[] body;
+        final String link;
+        final int delay;
+
+        Reply(int status, byte[] body, String link, int delay) {
+            this.status = status;
+            this.body = body;
+            this.link = link;
+            this.delay = delay;
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentryPage.java
 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentryPage.java
new file mode 100644
index 0000000000..86ba363eab
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentryPage.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.shade.com.fasterxml.jackson.core.JsonParser;
+import 
org.apache.seatunnel.shade.com.fasterxml.jackson.databind.DeserializationFeature;
+import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode;
+import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.utils.URLEncodedUtils;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+final class SentryPage {
+    private static final ObjectMapper MAPPER =
+            new ObjectMapper()
+                    .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+                    .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
+                    .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
+    private static final Pattern LINK = 
Pattern.compile("^\\s*<([^>]+)>\\s*(.*)$");
+    private static final Pattern PARAMETER =
+            
Pattern.compile("\\s*;\\s*([A-Za-z][A-Za-z0-9_-]*)\\s*=\\s*\\\"([^\\\"]*)\\\"\\s*");
+    final List<SeaTunnelRow> rows;
+    final String nextCursor;
+
+    SentryPage(byte[] body, String link, String endpoint) {
+        nextCursor = nextCursor(link, endpoint);
+        JsonNode root;
+        try {
+            root = MAPPER.readTree(body);
+        } catch (Exception e) {
+            throw SentryClient.failure("Invalid JSON event response (body 
withheld)");
+        }
+        if (root == null || !root.isArray()) {
+            throw SentryClient.failure("Expected a JSON array of Sentry 
events");
+        }
+        rows = new ArrayList<>(root.size());
+        for (JsonNode event : root) {
+            if (!event.isObject()) {
+                throw SentryClient.failure("Expected an event object");
+            }
+            rows.add(
+                    new SeaTunnelRow(
+                            new Object[] {
+                                text(event, "eventID", true), text(event, 
"groupID", false),
+                                        text(event, "projectID", false),
+                                text(event, "dateCreated", false), text(event, 
"title", false),
+                                        text(event, "message", false),
+                                text(event, "platform", false), 
event.toString()
+                            }));
+        }
+    }
+
+    private static String text(JsonNode event, String field, boolean required) 
{
+        JsonNode value = event.get(field);
+        if (value == null || value.isNull()) {
+            if (required) {
+                throw SentryClient.failure("Missing event field " + field);
+            }
+            return null;
+        }
+        if (!value.isTextual() || (required && value.textValue().isEmpty())) {
+            throw SentryClient.failure("Invalid event field " + field + " 
(value withheld)");
+        }
+        return value.textValue();
+    }
+
+    /** Use only the cursor, never a server-provided URL or rewritten query 
bounds. */
+    static String nextCursor(String header, String endpoint) {
+        if (header == null || header.length() > 16384) {
+            throw SentryClient.failure("Missing or oversized Sentry Link 
header");
+        }
+        boolean found = false;
+        String next = null;
+        for (String part : header.split(",(?=\\s*<)")) {
+            Matcher link = LINK.matcher(part);
+            if (!link.matches()) {
+                throw SentryClient.failure("Malformed Sentry Link header");
+            }
+            Map<String, String> parameters = new HashMap<>();
+            Matcher parameter = PARAMETER.matcher(link.group(2));
+            int position = 0;
+            while (parameter.find()) {
+                if (parameter.start() != position
+                        || parameters.put(parameter.group(1), 
parameter.group(2)) != null) {
+                    throw SentryClient.failure("Malformed Sentry Link 
parameters");
+                }
+                position = parameter.end();
+            }
+            if (position != link.group(2).length()) {
+                throw SentryClient.failure("Malformed Sentry Link parameters");
+            }
+            if (!"next".equals(parameters.get("rel"))) {
+                continue;
+            }
+            if (found) {
+                throw SentryClient.failure("Duplicate next-page links");
+            }
+            found = true;
+            String results = parameters.get("results");
+            if (!"true".equals(results) && !"false".equals(results)) {
+                throw SentryClient.failure("Missing or invalid next-page 
results flag");
+            }
+            URI uri;
+            try {
+                uri = URI.create(link.group(1));
+            } catch (RuntimeException e) {
+                throw SentryClient.failure("Invalid next-page URI");
+            }
+            URI expected = URI.create(endpoint);
+            if (!expected.getScheme().equals(uri.getScheme())
+                    || 
!expected.getRawAuthority().equals(uri.getRawAuthority())
+                    || !expected.getRawPath().equals(uri.getRawPath())
+                    || uri.getRawFragment() != null) {
+                throw SentryClient.failure("Next-page URI changed Sentry 
origin or endpoint");
+            }
+            if ("false".equals(results)) {
+                continue;
+            }
+            String cursor = null;
+            boolean cursorFound = false;
+            for (NameValuePair query : URLEncodedUtils.parse(uri, 
StandardCharsets.UTF_8)) {
+                if ("cursor".equals(query.getName())) {
+                    if (cursorFound) {
+                        throw SentryClient.failure("Duplicate next-page 
cursor");
+                    }
+                    cursorFound = true;
+                    cursor = query.getValue();
+                }
+            }
+            if (cursor == null
+                    || cursor.isEmpty()
+                    || cursor.length() > 2048
+                    || cursor.chars().anyMatch(c -> c < 33 || c > 126)
+                    || (parameters.containsKey("cursor")
+                            && !cursor.equals(parameters.get("cursor")))) {
+                throw SentryClient.failure("Missing or invalid next-page 
cursor");
+            }
+            next = cursor;
+        }
+        if (!found) {
+            throw SentryClient.failure("Missing next-page relation in Sentry 
Link header");
+        }
+        return next;
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySource.java
 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySource.java
new file mode 100644
index 0000000000..a0a4b748ac
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySource.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.api.common.JobContext;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.source.Boundedness;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TablePath;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.constants.JobMode;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitSource;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext;
+
+import java.util.Collections;
+import java.util.List;
+
+/** Bounded error-event extraction for one project, without remote snapshot 
guarantees. */
+public class SentrySource extends AbstractSingleSplitSource<SeaTunnelRow> {
+    private final SentrySourceConfig config;
+
+    public SentrySource(ReadonlyConfig options) {
+        config = new SentrySourceConfig(options);
+    }
+
+    @Override
+    public String getPluginName() {
+        return "Sentry";
+    }
+
+    @Override
+    public Boundedness getBoundedness() {
+        return Boundedness.BOUNDED;
+    }
+
+    @Override
+    public void setJobContext(JobContext context) {
+        if (context.getJobMode() != JobMode.BATCH) {
+            throw SentrySourceConfig.failure("Sentry source supports BATCH 
only");
+        }
+    }
+
+    @Override
+    public List<CatalogTable> getProducedCatalogTables() {
+        TableSchema.Builder schema = TableSchema.builder();
+        String[] fields = {
+            "event_id",
+            "group_id",
+            "project_id",
+            "date_created",
+            "title",
+            "message",
+            "platform",
+            "content"
+        };
+        for (String name : fields) {
+            schema.column(
+                    PhysicalColumn.of(
+                            name,
+                            BasicType.STRING_TYPE,
+                            0,
+                            !name.equals("event_id") && 
!name.equals("content"),
+                            null,
+                            null));
+        }
+        return Collections.singletonList(
+                CatalogTable.of(
+                        TableIdentifier.of("Sentry", TablePath.DEFAULT),
+                        schema.build(),
+                        Collections.emptyMap(),
+                        Collections.emptyList(),
+                        null));
+    }
+
+    @Override
+    public AbstractSingleSplitReader<SeaTunnelRow> 
createReader(SingleSplitReaderContext context) {
+        return new SentrySourceReader(config, context);
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceConfig.java
 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceConfig.java
new file mode 100644
index 0000000000..2c82881e6a
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceConfig.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.api.configuration.Option;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConfigValidator;
+import org.apache.seatunnel.api.options.EnvCommonOptions;
+import org.apache.seatunnel.api.options.MultiTableCommonOptions;
+import org.apache.seatunnel.api.options.MultiTableFailurePolicy;
+import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
+import 
org.apache.seatunnel.connectors.seatunnel.sentry.exception.SentryConnectorException;
+
+import java.io.Serializable;
+import java.net.URI;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.util.HashMap;
+import java.util.Map;
+
+final class SentrySourceConfig implements Serializable {
+    private static final long serialVersionUID = 1L;
+    final String token;
+    final String endpoint;
+    final Instant start;
+    final Instant end;
+    final int pageSize;
+    final int maxPages;
+    final int retries;
+    final int retryDelay;
+    final int timeout;
+    final int maxBytes;
+
+    SentrySourceConfig(ReadonlyConfig options) {
+        // Zeta injects this engine-owned policy even for single-table sources.
+        if (get(options, MultiTableCommonOptions.MULTI_TABLE_FAILURE_POLICY)
+                != MultiTableFailurePolicy.FAIL_FAST) {
+            throw failure("Sentry source supports only 
multi_table.failure_policy=FAIL_FAST");
+        }
+        Map<String, Object> validation = new HashMap<>(options.getSourceMap());
+        
validation.remove(MultiTableCommonOptions.MULTI_TABLE_FAILURE_POLICY.key());
+        if (validation.get("multi_table") instanceof Map) {
+            Map<String, Object> nested =
+                    new HashMap<>((Map<String, Object>) 
validation.get("multi_table"));
+            nested.remove("failure_policy");
+            if (nested.isEmpty()) {
+                validation.remove("multi_table");
+            } else {
+                validation.put("multi_table", nested);
+            }
+        }
+        ConfigValidator.validateUnknownKeys(
+                ReadonlyConfig.fromMap(validation),
+                new SentrySourceFactory().optionRule(),
+                "Sentry");
+        if (options.getSourceMap().containsKey("schema")) {
+            throw failure("Sentry source uses a fixed schema");
+        }
+        if (get(options, EnvCommonOptions.PARALLELISM) != 1) {
+            throw failure("Sentry source requires parallelism=1");
+        }
+        token = get(options, SentrySourceOptions.TOKEN);
+        if (token == null || token.length() > 8192 || 
!token.matches("[A-Za-z0-9._~+/-]+=*")) {
+            throw failure("Invalid token (value withheld)");
+        }
+        boolean mock = get(options, SentrySourceOptions.MOCK_MODE);
+        if (mock && !token.equals("mock-token")) {
+            throw failure("mock_mode requires token=mock-token");
+        }
+        String origin = origin(get(options, SentrySourceOptions.API_BASE_URL), 
mock);
+        endpoint =
+                origin
+                        + "/api/0/projects/"
+                        + slug(get(options, SentrySourceOptions.ORGANIZATION), 
"organization")
+                        + "/"
+                        + slug(get(options, SentrySourceOptions.PROJECT), 
"project")
+                        + "/events/";
+        start = date(get(options, SentrySourceOptions.START_TIME));
+        end = date(get(options, SentrySourceOptions.END_TIME));
+        if (!start.isBefore(end)) {
+            throw failure("start_time must be earlier than end_time");
+        }
+        pageSize = range(get(options, SentrySourceOptions.PAGE_SIZE), 1, 100, 
"page_size");
+        maxPages = range(get(options, SentrySourceOptions.MAX_PAGES), 1, 
100000, "max_pages");
+        retries = range(get(options, SentrySourceOptions.MAX_RETRIES), 0, 5, 
"max_retries");
+        retryDelay =
+                range(get(options, SentrySourceOptions.RETRY_DELAY_MS), 1, 
60000, "retry_delay_ms");
+        timeout =
+                range(
+                        get(options, SentrySourceOptions.REQUEST_TIMEOUT_MS),
+                        1,
+                        120000,
+                        "request_timeout_ms");
+        maxBytes =
+                range(
+                        get(options, SentrySourceOptions.MAX_RESPONSE_BYTES),
+                        1024,
+                        16777216,
+                        "max_response_bytes");
+    }
+
+    private static <T> T get(ReadonlyConfig config, Option<T> option) {
+        try {
+            return config.get(option);
+        } catch (RuntimeException e) {
+            throw failure("Invalid option " + option.key() + " (value 
withheld)");
+        }
+    }
+
+    private static String origin(String value, boolean mock) {
+        try {
+            URI uri = URI.create(value);
+            if (uri.getHost() == null
+                    || uri.getPort() == 0
+                    || uri.getPort() > 65535
+                    || uri.getRawUserInfo() != null
+                    || uri.getRawQuery() != null
+                    || uri.getRawFragment() != null
+                    || !uri.getRawPath().isEmpty()
+                    || !("https".equals(uri.getScheme())
+                            || (mock && "http".equals(uri.getScheme())))) {
+                throw new IllegalArgumentException();
+            }
+            return value;
+        } catch (RuntimeException e) {
+            throw failure(
+                    "api_base_url must be an HTTPS origin without user info, 
path, query or fragment");
+        }
+    }
+
+    private static String slug(String value, String name) {
+        if (value == null || !value.matches("[A-Za-z0-9_-]{1,200}")) {
+            throw failure("Invalid " + name + " ID or slug");
+        }
+        return value;
+    }
+
+    private static Instant date(String value) {
+        try {
+            if (value == null
+                    || !value.matches(
+                            
"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})"))
 {
+                throw new IllegalArgumentException();
+            }
+            return OffsetDateTime.parse(value).toInstant();
+        } catch (RuntimeException e) {
+            throw failure(
+                    "start_time and end_time must be absolute RFC3339 
timestamps with offsets");
+        }
+    }
+
+    private static int range(int value, int min, int max, String name) {
+        if (value < min || value > max) {
+            throw failure(name + " must be between " + min + " and " + max);
+        }
+        return value;
+    }
+
+    static SentryConnectorException failure(String message) {
+        return new 
SentryConnectorException(CommonErrorCodeDeprecated.ILLEGAL_ARGUMENT, message);
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceFactory.java
 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceFactory.java
new file mode 100644
index 0000000000..da80a39a7f
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceFactory.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.api.configuration.util.OptionRule;
+import org.apache.seatunnel.api.source.SeaTunnelSource;
+import org.apache.seatunnel.api.source.SourceSplit;
+import org.apache.seatunnel.api.table.connector.TableSource;
+import org.apache.seatunnel.api.table.factory.Factory;
+import org.apache.seatunnel.api.table.factory.TableSourceFactory;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+
+import com.google.auto.service.AutoService;
+
+import java.io.Serializable;
+
+@AutoService(Factory.class)
+public class SentrySourceFactory implements TableSourceFactory {
+    @Override
+    public String factoryIdentifier() {
+        return "Sentry";
+    }
+
+    @Override
+    public OptionRule optionRule() {
+        return OptionRule.builder()
+                .required(
+                        SentrySourceOptions.TOKEN,
+                        SentrySourceOptions.ORGANIZATION,
+                        SentrySourceOptions.PROJECT,
+                        SentrySourceOptions.START_TIME,
+                        SentrySourceOptions.END_TIME)
+                .optional(
+                        SentrySourceOptions.API_BASE_URL,
+                        SentrySourceOptions.PAGE_SIZE,
+                        SentrySourceOptions.MAX_PAGES,
+                        SentrySourceOptions.MAX_RETRIES,
+                        SentrySourceOptions.RETRY_DELAY_MS,
+                        SentrySourceOptions.REQUEST_TIMEOUT_MS,
+                        SentrySourceOptions.MAX_RESPONSE_BYTES,
+                        SentrySourceOptions.MOCK_MODE)
+                .build();
+    }
+
+    @Override
+    public <T, SplitT extends SourceSplit, StateT extends Serializable>
+            TableSource<T, SplitT, StateT> 
createSource(TableSourceFactoryContext context) {
+        return () -> (SeaTunnelSource<T, SplitT, StateT>) new 
SentrySource(context.getOptions());
+    }
+
+    @Override
+    public Class<? extends SeaTunnelSource> getSourceClass() {
+        return SentrySource.class;
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceOptions.java
 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceOptions.java
new file mode 100644
index 0000000000..7eb5c3b808
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceOptions.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.api.configuration.Option;
+import org.apache.seatunnel.api.configuration.Options;
+
+public final class SentrySourceOptions {
+    public static final Option<String> TOKEN =
+            Options.key("token")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription("Bearer token with project:read access; 
not a sink DSN.");
+    public static final Option<String> ORGANIZATION =
+            Options.key("organization")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription("Sentry organization ID or slug.");
+    public static final Option<String> PROJECT =
+            Options.key("project")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription("Sentry project ID or slug.");
+    public static final Option<String> START_TIME =
+            Options.key("start_time")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription("Absolute RFC3339 start of the event 
query.");
+    public static final Option<String> END_TIME =
+            Options.key("end_time")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription("Absolute RFC3339 end of the event 
query.");
+    public static final Option<String> API_BASE_URL =
+            Options.key("api_base_url")
+                    .stringType()
+                    .defaultValue("https://sentry.io";)
+                    .withDescription(
+                            "HTTPS origin of Sentry or a self-hosted 
deployment; no path.");
+    public static final Option<Integer> PAGE_SIZE =
+            Options.key("page_size")
+                    .intType()
+                    .defaultValue(100)
+                    .withDescription("Number of events requested per page, 1 
to 100.");
+    public static final Option<Integer> MAX_PAGES =
+            Options.key("max_pages")
+                    .intType()
+                    .defaultValue(10000)
+                    .withDescription(
+                            "Fail rather than truncate if the query requires 
more pages, 1 to 100000.");
+    public static final Option<Integer> MAX_RETRIES =
+            Options.key("max_retries")
+                    .intType()
+                    .defaultValue(3)
+                    .withDescription(
+                            "Additional attempts for transport failures and 
HTTP 429/500/502/503/504, 0 to 5.");
+    public static final Option<Integer> RETRY_DELAY_MS =
+            Options.key("retry_delay_ms")
+                    .intType()
+                    .defaultValue(1000)
+                    .withDescription("Minimum retry delay, 1 to 60000 
milliseconds.");
+    public static final Option<Integer> REQUEST_TIMEOUT_MS =
+            Options.key("request_timeout_ms")
+                    .intType()
+                    .defaultValue(30000)
+                    .withDescription(
+                            "Per-attempt HTTP connect/socket timeout and abort 
deadline, 1 to 120000 milliseconds; not a total job deadline.");
+    public static final Option<Integer> MAX_RESPONSE_BYTES =
+            Options.key("max_response_bytes")
+                    .intType()
+                    .defaultValue(8388608)
+                    .withDescription("Maximum uncompressed response size, 1024 
to 16777216 bytes.");
+    public static final Option<Boolean> MOCK_MODE =
+            Options.key("mock_mode")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "Allow HTTP fixtures only with token=mock-token; 
never use with real credentials.");
+
+    private SentrySourceOptions() {}
+}
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceReader.java
 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceReader.java
new file mode 100644
index 0000000000..cf1f37a022
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/main/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceReader.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext;
+import 
org.apache.seatunnel.connectors.seatunnel.sentry.exception.SentryConnectorException;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+final class SentrySourceReader extends AbstractSingleSplitReader<SeaTunnelRow> 
{
+    static SentryConnectorException failure(String message) {
+        return new SentryConnectorException(
+                CommonErrorCodeDeprecated.READER_OPERATION_FAILED, message);
+    }
+
+    private final SentrySourceConfig config;
+    private final SingleSplitReaderContext context;
+    private volatile boolean closed;
+    private SentryClient client;
+
+    SentrySourceReader(SentrySourceConfig config, SingleSplitReaderContext 
context) {
+        this.config = config;
+        this.context = context;
+    }
+
+    /** Create HTTP resources on the worker, not inside serialized 
configuration or split state. */
+    @Override
+    public synchronized void open() {
+        if (closed || client != null) {
+            throw failure("Reader closed or already opened");
+        }
+        client = new SentryClient(config);
+    }
+
+    /** Read a complete bounded window. Recovery replays the window, not 
unstable remote offsets. */
+    @Override
+    public void internalPollNext(Collector<SeaTunnelRow> output) throws 
Exception {
+        Set<String> visited = new HashSet<>();
+        String cursor = null;
+        for (int page = 0; ; page++) {
+            checkOpen();
+            if (page >= config.maxPages) {
+                throw failure("Sentry query exceeds max_pages; use a smaller 
time window");
+            }
+            SentryPage result = client.page(cursor);
+            if (result.nextCursor != null && !visited.add(result.nextCursor)) {
+                throw failure("Sentry returned a repeated pagination cursor");
+            }
+            for (SeaTunnelRow row : result.rows) {
+                checkOpen();
+                output.collect(row);
+            }
+            cursor = result.nextCursor;
+            if (cursor == null) {
+                break;
+            }
+        }
+        checkOpen();
+        context.signalNoMoreElement();
+    }
+
+    private void checkOpen() {
+        if (closed || Thread.currentThread().isInterrupted() || client == 
null) {
+            throw failure("Sentry reader is not open or was cancelled");
+        }
+    }
+
+    /** Abort in-flight requests, wake retry waits, and release all 
worker-local resources. */
+    @Override
+    public synchronized void close() throws IOException {
+        closed = true;
+        if (client != null) {
+            client.close();
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/test/java/org/apache/seatunnel/connectors/seatunnel/sentry/SentryFactoryTest.java
 
b/seatunnel-connectors-v2/connector-sentry/src/test/java/org/apache/seatunnel/connectors/seatunnel/sentry/SentryFactoryTest.java
index 3bb59eb44c..316edc5fde 100644
--- 
a/seatunnel-connectors-v2/connector-sentry/src/test/java/org/apache/seatunnel/connectors/seatunnel/sentry/SentryFactoryTest.java
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/test/java/org/apache/seatunnel/connectors/seatunnel/sentry/SentryFactoryTest.java
@@ -21,6 +21,8 @@ import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.configuration.util.ConfigValidator;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+import org.apache.seatunnel.api.table.factory.Factory;
+import org.apache.seatunnel.api.table.factory.TableSourceFactory;
 import 
org.apache.seatunnel.connectors.seatunnel.sentry.config.SentrySinkOptions;
 import org.apache.seatunnel.connectors.seatunnel.sentry.sink.SentrySinkFactory;
 
@@ -29,9 +31,22 @@ import org.junit.jupiter.api.Test;
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.ServiceLoader;
 
 class SentryFactoryTest {
 
+    @Test
+    void testSourceDiscovery() {
+        boolean found = false;
+        for (Factory factory : ServiceLoader.load(Factory.class)) {
+            if (factory instanceof TableSourceFactory
+                    && "Sentry".equals(factory.factoryIdentifier())) {
+                found = true;
+            }
+        }
+        Assertions.assertTrue(found, "Sentry must expose a source factory");
+    }
+
     private final OptionRule optionRule = new SentrySinkFactory().optionRule();
 
     @Test
diff --git 
a/seatunnel-connectors-v2/connector-sentry/src/test/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceTest.java
 
b/seatunnel-connectors-v2/connector-sentry/src/test/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceTest.java
new file mode 100644
index 0000000000..b2ca77a8f2
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-sentry/src/test/java/org/apache/seatunnel/connectors/seatunnel/sentry/source/SentrySourceTest.java
@@ -0,0 +1,685 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.sentry.source;
+
+import org.apache.seatunnel.api.common.JobContext;
+import org.apache.seatunnel.api.common.multitable.MultiTableFailureHelper;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.source.SourceReader;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.constants.JobMode;
+import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader;
+import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplit;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitEnumeratorState;
+import 
org.apache.seatunnel.connectors.seatunnel.sentry.exception.SentryConnectorException;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class SentrySourceTest {
+
+    @Test
+    void distinguishesConfigurationErrorsFromReaderFailures() throws Exception 
{
+        Map<String, Object> invalid = options();
+        invalid.put("page_size", 0);
+        SentryConnectorException configError =
+                Assertions.assertThrows(SentryConnectorException.class, () -> 
source(invalid));
+        Assertions.assertEquals(
+                CommonErrorCodeDeprecated.ILLEGAL_ARGUMENT, 
configError.getSeaTunnelErrorCode());
+        AbstractSingleSplitReader<SeaTunnelRow> reader =
+                reader(options(), mock(SourceReader.Context.class));
+        reader.close();
+        SentryConnectorException readerError =
+                Assertions.assertThrows(
+                        SentryConnectorException.class,
+                        () -> reader.pollNext(collector(new ArrayList<>())));
+        Assertions.assertEquals(
+                CommonErrorCodeDeprecated.READER_OPERATION_FAILED,
+                readerError.getSeaTunnelErrorCode());
+    }
+
+    @Test
+    void acceptsEngineInjectedFailFastButRejectsUnsupportedFailurePolicy() {
+        ReadonlyConfig injected =
+                MultiTableFailureHelper.withMultiTableFailurePolicy(
+                        ReadonlyConfig.fromMap(options()),
+                        ReadonlyConfig.fromMap(Collections.emptyMap()));
+        Assertions.assertNotNull(
+                new SentrySourceFactory()
+                        .createSource(
+                                new TableSourceFactoryContext(
+                                        injected, getClass().getClassLoader()))
+                        .createSource());
+        Map<String, Object> config = options();
+        config.put("multi_table.failure_policy", "CONTINUE_OTHER_TABLES");
+        assertFailure(() -> source(config), "FAIL_FAST");
+        config.put("multi_table.failure_policy", "invalid");
+        assertFailure(() -> source(config), "Invalid option 
multi_table.failure_policy");
+    }
+
+    private HttpServer server;
+    private ExecutorService executor;
+    private final List<AbstractSingleSplitReader<SeaTunnelRow>> readers = new 
ArrayList<>();
+    private static final String PATH = "/api/0/projects/acme/demo/events/";
+
+    @AfterEach
+    void close() throws Exception {
+        for (AbstractSingleSplitReader<SeaTunnelRow> reader : readers) {
+            reader.close();
+        }
+        if (server != null) {
+            server.stop(0);
+        }
+        if (executor != null) {
+            executor.shutdownNow();
+            Assertions.assertTrue(executor.awaitTermination(5, 
TimeUnit.SECONDS));
+        }
+    }
+
+    @Test
+    void factoryCreatesSourceWithFixedSchemaAndSupportedClass() throws 
Exception {
+        SentrySource source = source(options());
+        Assertions.assertEquals(SentrySource.class, new 
SentrySourceFactory().getSourceClass());
+        Assertions.assertEquals(
+                8, 
source.getProducedCatalogTables().get(0).getTableSchema().getColumns().size());
+        Assertions.assertEquals(
+                "event_id",
+                source.getProducedCatalogTables()
+                        .get(0)
+                        .getTableSchema()
+                        .getColumns()
+                        .get(0)
+                        .getName());
+        JobContext job = mock(JobContext.class);
+        when(job.getJobMode()).thenReturn(JobMode.STREAMING);
+        assertFailure(() -> source.setJobContext(job), "BATCH only");
+    }
+
+    @Test
+    void rejectsIgnoredAndInvalidOptionsWithoutLeakingValues() {
+        for (String key :
+                Arrays.asList("schema", "query", "multi_table_failure_policy", 
"dsn", "full")) {
+            Map<String, Object> options = options();
+            options.put(key, "unsupported");
+            Assertions.assertThrows(RuntimeException.class, () -> 
source(options));
+        }
+        Map<String, Object> options = options();
+        options.put("token", "secret\r\nvalue");
+        SentryConnectorException error =
+                Assertions.assertThrows(SentryConnectorException.class, () -> 
source(options));
+        Assertions.assertFalse(error.toString().contains("secret"));
+        options.put("token", "real-token");
+        options.put("mock_mode", true);
+        assertFailure(() -> source(options), "mock_mode");
+    }
+
+    @Test
+    void rejectsInvalidBoundsOriginsAndParallelism() {
+        Map<String, Object> invalid = new HashMap<>();
+        invalid.put("page_size", 101);
+        invalid.put("max_pages", 0);
+        invalid.put("max_retries", 6);
+        invalid.put("retry_delay_ms", 0);
+        invalid.put("request_timeout_ms", 0);
+        invalid.put("max_response_bytes", Integer.MAX_VALUE);
+        invalid.put("parallelism", 2);
+        invalid.put("organization", "../other");
+        invalid.put("project", "a/b");
+        invalid.put("start_time", "2026-01-03T00:00:00Z");
+        invalid.put("end_time", "bad");
+        invalid.put("api_base_url", "https://user:[email protected]/private";);
+        for (Map.Entry<String, Object> entry : invalid.entrySet()) {
+            Map<String, Object> options = options();
+            options.put(entry.getKey(), entry.getValue());
+            Assertions.assertThrows(
+                    SentryConnectorException.class, () -> source(options), 
entry.getKey());
+        }
+        for (String url :
+                Arrays.asList(
+                        "http://example.com";,
+                        "https://example.com/";,
+                        "https://example.com?secret=1";,
+                        "https://example.com#x";)) {
+            Map<String, Object> options = options();
+            options.put("api_base_url", url);
+            assertFailure(() -> source(options), "HTTPS origin");
+        }
+    }
+
+    @Test
+    void readsTwoPagesThroughFactoryAndReaderAndStopsOnFalseResults() throws 
Exception {
+        AtomicInteger requests = new AtomicInteger();
+        start(
+                exchange -> {
+                    int page = requests.incrementAndGet();
+                    Assertions.assertEquals(
+                            "Bearer mock-token",
+                            
exchange.getRequestHeaders().getFirst("Authorization"));
+                    String query = exchange.getRequestURI().getRawQuery();
+                    
Assertions.assertTrue(query.contains("start=2026-01-01T00%3A00%3A00Z"));
+                    
Assertions.assertTrue(query.contains("end=2026-01-02T00%3A00%3A00Z"));
+                    Assertions.assertTrue(query.contains("per_page=2"));
+                    Assertions.assertTrue(query.contains("full=false"));
+                    if (page == 1) {
+                        Assertions.assertFalse(query.contains("cursor="));
+                    } else {
+                        
Assertions.assertTrue(query.contains("cursor=0%3A2%3A0"));
+                    }
+                    reply(
+                            exchange,
+                            200,
+                            page == 1
+                                    ? "[" + event("a") + "," + event("b") + "]"
+                                    : "[" + event("c") + "]",
+                            link(page == 1, "0:2:0"));
+                });
+        SourceReader.Context context = mock(SourceReader.Context.class);
+        AbstractSingleSplitReader<SeaTunnelRow> reader = 
reader(localOptions(), context);
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        Collector<SeaTunnelRow> collector = collector(rows);
+        reader.pollNext(collector);
+        reader.pollNext(collector);
+        Assertions.assertEquals(2, requests.get());
+        Assertions.assertEquals(
+                Arrays.asList("a", "b", "c"),
+                Arrays.asList(
+                        rows.get(0).getField(0), rows.get(1).getField(0), 
rows.get(2).getField(0)));
+        Assertions.assertEquals("Résumé ✓", rows.get(0).getField(4));
+        Assertions.assertNull(rows.get(0).getField(5));
+        Assertions.assertTrue(
+                
rows.get(0).getField(7).toString().contains("0.12345678901234567890123456789"));
+        verify(context).signalNoMoreElement();
+    }
+
+    @Test
+    void emptyFinalPageCompletesWithoutExtraRequest() throws Exception {
+        AtomicInteger count = new AtomicInteger();
+        start(
+                exchange -> {
+                    count.incrementAndGet();
+                    reply(exchange, 200, "[]", link(false, "0:0:0"));
+                });
+        SourceReader.Context context = mock(SourceReader.Context.class);
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        reader(localOptions(), context).pollNext(collector(rows));
+        Assertions.assertTrue(rows.isEmpty());
+        Assertions.assertEquals(1, count.get());
+        verify(context).signalNoMoreElement();
+    }
+
+    @Test
+    void retriesTransientFailureWithoutEmittingDuplicates() throws Exception {
+        AtomicInteger count = new AtomicInteger();
+        start(
+                exchange -> {
+                    if (count.incrementAndGet() == 1) {
+                        exchange.getResponseHeaders().add("Retry-After", "0");
+                        reply(exchange, 429, "private", null);
+                    } else {
+                        reply(exchange, 200, "[" + event("a") + "]", 
link(false, "0:0:0"));
+                    }
+                });
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        reader(localOptions(), 
mock(SourceReader.Context.class)).pollNext(collector(rows));
+        Assertions.assertEquals(2, count.get());
+        Assertions.assertEquals(1, rows.size());
+    }
+
+    @Test
+    void doesNotRetryPermissionFailuresOrRedirectCredentials() throws 
Exception {
+        AtomicInteger count = new AtomicInteger();
+        start(
+                exchange -> {
+                    count.incrementAndGet();
+                    exchange.getResponseHeaders().add("Location", origin() + 
"/unexpected");
+                    reply(exchange, 302, "private-event-and-token", null);
+                });
+        assertFailure(
+                () ->
+                        reader(localOptions(), 
mock(SourceReader.Context.class))
+                                .pollNext(collector(new ArrayList<>())),
+                "HTTP 302");
+        Assertions.assertEquals(1, count.get());
+    }
+
+    @Test
+    void boundsRetryBudgetAndWithholdsRemoteBody() throws Exception {
+        AtomicInteger count = new AtomicInteger();
+        start(
+                exchange -> {
+                    count.incrementAndGet();
+                    reply(exchange, 503, "private-event-and-token", null);
+                });
+        Map<String, Object> options = localOptions();
+        options.put("max_retries", 2);
+        SentryConnectorException error =
+                Assertions.assertThrows(
+                        SentryConnectorException.class,
+                        () ->
+                                reader(options, 
mock(SourceReader.Context.class))
+                                        .pollNext(collector(new 
ArrayList<>())));
+        Assertions.assertTrue(error.getMessage().contains("retry budget"));
+        Assertions.assertEquals(
+                CommonErrorCodeDeprecated.HTTP_OPERATION_FAILED, 
error.getSeaTunnelErrorCode());
+        Assertions.assertFalse(error.toString().contains("private-event"));
+        Assertions.assertNull(error.getCause());
+        Assertions.assertEquals(3, count.get());
+    }
+
+    @Test
+    void permissionFailureIsNotRetried() throws Exception {
+        AtomicInteger count = new AtomicInteger();
+        start(
+                exchange -> {
+                    count.incrementAndGet();
+                    reply(exchange, 403, "private", null);
+                });
+        assertFailure(
+                () ->
+                        reader(localOptions(), 
mock(SourceReader.Context.class))
+                                .pollNext(collector(new ArrayList<>())),
+                "HTTP 403");
+        Assertions.assertEquals(1, count.get());
+    }
+
+    @Test
+    void rejectsMalformedPayloadBeforeEmittingRows() {
+        String endpoint = "https://sentry.io"; + PATH;
+        String link = "<" + endpoint + "?cursor=0:0:0>; rel=\"next\"; 
results=\"false\"";
+        for (String body :
+                Arrays.asList(
+                        "{}",
+                        "null",
+                        "[null]",
+                        "[{}]",
+                        "[" + event("a") + ",{}]",
+                        "[{\"eventID\":3}]",
+                        "[{\"eventID\":\"a\",\"eventID\":\"b\"}]",
+                        "[" + event("a") + "] {}")) {
+            SentryConnectorException error =
+                    Assertions.assertThrows(
+                            SentryConnectorException.class,
+                            () ->
+                                    new SentryPage(
+                                            
body.getBytes(StandardCharsets.UTF_8), link, endpoint),
+                            body);
+            Assertions.assertEquals(
+                    CommonErrorCodeDeprecated.HTTP_OPERATION_FAILED, 
error.getSeaTunnelErrorCode());
+        }
+    }
+
+    @Test
+    void validatesLinkContractAndNeverTrustsForeignUrls() {
+        String endpoint = "https://sentry.io"; + PATH;
+        for (String header :
+                Arrays.asList(
+                        null,
+                        "",
+                        "<" + endpoint + ">; rel=\"next\"",
+                        "<" + endpoint + ">; rel=\"previous\"; 
results=\"false\"",
+                        "<https://evil.example";
+                                + PATH
+                                + "?cursor=a>; rel=\"next\"; results=\"true\"",
+                        "<" + endpoint + "?cursor=a>; rel=\"next\"; 
results=\"true\"; cursor=\"b\"",
+                        "<" + endpoint + "?cursor=a&cursor=b>; rel=\"next\"; 
results=\"true\"",
+                        "<" + endpoint + "?cursor&cursor=b>; rel=\"next\"; 
results=\"true\"",
+                        "<" + endpoint + "?cursor=a>; rel=\"next\"; 
results=\"maybe\"",
+                        "<"
+                                + endpoint
+                                + "?cursor=a>; rel=\"next\"; results=\"true\", 
<"
+                                + endpoint
+                                + "?cursor=b>; rel=\"next\"; 
results=\"true\"")) {
+            Assertions.assertThrows(
+                    SentryConnectorException.class, () -> 
SentryPage.nextCursor(header, endpoint));
+        }
+        Assertions.assertEquals(
+                "0:100:0",
+                SentryPage.nextCursor(
+                        "<"
+                                + endpoint
+                                + "?cursor=0%3A100%3A0>; rel=\"next\"; 
results=\"true\"; cursor=\"0:100:0\"",
+                        endpoint));
+    }
+
+    @Test
+    void repeatedCursorFailsInsteadOfCompleting() throws Exception {
+        start(exchange -> reply(exchange, 200, "[" + event("a") + "]", 
link(true, "0:2:0")));
+        SourceReader.Context context = mock(SourceReader.Context.class);
+        assertFailure(
+                () -> reader(localOptions(), context).pollNext(collector(new 
ArrayList<>())),
+                "repeated pagination cursor");
+        verify(context, never()).signalNoMoreElement();
+    }
+
+    @Test
+    void maxPagesFailsRatherThanTruncating() throws Exception {
+        start(exchange -> reply(exchange, 200, "[]", link(true, "0:2:0")));
+        Map<String, Object> options = localOptions();
+        options.put("max_pages", 1);
+        SourceReader.Context context = mock(SourceReader.Context.class);
+        assertFailure(
+                () -> reader(options, context).pollNext(collector(new 
ArrayList<>())), "max_pages");
+        verify(context, never()).signalNoMoreElement();
+    }
+
+    @Test
+    void limitsChunkedBodyAndDoesNotCompleteOnMissingPagination() throws 
Exception {
+        start(
+                exchange -> {
+                    exchange.getResponseHeaders().add("Link", link(false, 
"0:0:0"));
+                    exchange.sendResponseHeaders(200, 0);
+                    exchange.getResponseBody().write(new byte[2048]);
+                    exchange.close();
+                });
+        Map<String, Object> options = localOptions();
+        options.put("max_response_bytes", 1024);
+        assertFailure(
+                () ->
+                        reader(options, mock(SourceReader.Context.class))
+                                .pollNext(collector(new ArrayList<>())),
+                "max_response_bytes");
+    }
+
+    @Test
+    void respectsRetryAfterBoundsAndHttpDate() {
+        Instant now = Instant.parse("2026-01-01T00:00:00Z");
+        Assertions.assertEquals(1000, SentryClient.retryDelay("0", 1000, now));
+        Assertions.assertEquals(
+                2000, SentryClient.retryDelay("Thu, 1 Jan 2026 00:00:02 GMT", 
1, now));
+        assertFailure(() -> SentryClient.retryDelay("61", 1, now), 
"Retry-After");
+        assertFailure(() -> SentryClient.retryDelay("9223372036854775807", 1, 
now), "Retry-After");
+        assertFailure(() -> SentryClient.retryDelay("private-token", 1, now), 
"Retry-After");
+    }
+
+    @Test
+    void rejectsMissingLinkBeforeAnyRowsOrCompletion() throws Exception {
+        start(exchange -> reply(exchange, 200, "[" + event("a") + "]", null));
+        SourceReader.Context context = mock(SourceReader.Context.class);
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        assertFailure(
+                () -> reader(localOptions(), 
context).pollNext(collector(rows)), "Link header");
+        Assertions.assertTrue(rows.isEmpty());
+        verify(context, never()).signalNoMoreElement();
+    }
+
+    @Test
+    void rejectsOversizedHttpHeadersWithBoundedRedactedFailure() throws 
Exception {
+        start(
+                exchange -> {
+                    char[] padding = new char[20000];
+                    Arrays.fill(padding, 'x');
+                    exchange.getResponseHeaders().add("X-Example", new 
String(padding));
+                    reply(exchange, 200, "[]", link(false, "0:0:0"));
+                });
+        Map<String, Object> options = localOptions();
+        options.put("max_retries", 0);
+        assertFailure(
+                () ->
+                        reader(options, mock(SourceReader.Context.class))
+                                .pollNext(collector(new ArrayList<>())),
+                "retry budget");
+    }
+
+    @Test
+    void closeWakesLongRetryDelayWithoutCompleting() throws Exception {
+        CountDownLatch responseSent = new CountDownLatch(1);
+        start(
+                exchange -> {
+                    exchange.sendResponseHeaders(503, -1);
+                    exchange.close();
+                    responseSent.countDown();
+                });
+        Map<String, Object> options = localOptions();
+        options.put("retry_delay_ms", 60000);
+        SourceReader.Context context = mock(SourceReader.Context.class);
+        AbstractSingleSplitReader<SeaTunnelRow> reader = reader(options, 
context);
+        executor = Executors.newSingleThreadExecutor();
+        AtomicReference<Thread> pollingThread = new AtomicReference<>();
+        Future<?> future =
+                executor.submit(
+                        () -> {
+                            pollingThread.set(Thread.currentThread());
+                            return Assertions.assertThrows(
+                                    Exception.class,
+                                    () -> reader.pollNext(collector(new 
ArrayList<>())));
+                        });
+        Assertions.assertTrue(responseSent.await(5, TimeUnit.SECONDS));
+        org.awaitility.Awaitility.await()
+                .atMost(5, TimeUnit.SECONDS)
+                .until(
+                        () ->
+                                pollingThread.get() != null
+                                        && pollingThread.get().getState()
+                                                == Thread.State.TIMED_WAITING
+                                        && 
Arrays.stream(pollingThread.get().getStackTrace())
+                                                .anyMatch(
+                                                        frame ->
+                                                                
frame.getMethodName()
+                                                                        
.equals("retry")));
+        reader.close();
+        future.get(5, TimeUnit.SECONDS);
+        verify(context, never()).signalNoMoreElement();
+    }
+
+    @Test
+    void closingCancelsInFlightRequestAndPreventsCompletion() throws Exception 
{
+        CountDownLatch received = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        start(
+                exchange -> {
+                    received.countDown();
+                    try {
+                        release.await(5, TimeUnit.SECONDS);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    exchange.close();
+                });
+        SourceReader.Context context = mock(SourceReader.Context.class);
+        AbstractSingleSplitReader<SeaTunnelRow> reader = 
reader(localOptions(), context);
+        executor = Executors.newSingleThreadExecutor();
+        Future<?> future =
+                executor.submit(
+                        () -> {
+                            Assertions.assertThrows(
+                                    Exception.class,
+                                    () -> reader.pollNext(collector(new 
ArrayList<>())));
+                        });
+        Assertions.assertTrue(received.await(5, TimeUnit.SECONDS));
+        reader.close();
+        future.get(5, TimeUnit.SECONDS);
+        release.countDown();
+        verify(context, never()).signalNoMoreElement();
+    }
+
+    @Test
+    void requestDeadlineBoundsStalledResponse() throws Exception {
+        CountDownLatch release = new CountDownLatch(1);
+        start(
+                exchange -> {
+                    try {
+                        release.await(5, TimeUnit.SECONDS);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    exchange.close();
+                });
+        Map<String, Object> options = localOptions();
+        options.put("request_timeout_ms", 50);
+        options.put("max_retries", 0);
+        try {
+            assertFailure(
+                    () ->
+                            reader(options, mock(SourceReader.Context.class))
+                                    .pollNext(collector(new ArrayList<>())),
+                    "retry budget");
+        } finally {
+            release.countDown();
+        }
+    }
+
+    @Test
+    void checkpointContainsNoCredentialsAndRestorationReplaysWindow() throws 
Exception {
+        AtomicInteger count = new AtomicInteger();
+        start(
+                exchange -> {
+                    count.incrementAndGet();
+                    reply(exchange, 200, "[" + event("a") + "]", link(false, 
"0:0:0"));
+                });
+        AbstractSingleSplitReader<SeaTunnelRow> first =
+                reader(localOptions(), mock(SourceReader.Context.class));
+        List<SingleSplit> state = first.snapshotState(1);
+        Assertions.assertNull(state.get(0).getState());
+        first.pollNext(collector(new ArrayList<>()));
+        AbstractSingleSplitReader<SeaTunnelRow> restored =
+                reader(localOptions(), mock(SourceReader.Context.class));
+        restored.addSplits(state);
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        restored.pollNext(collector(rows));
+        Assertions.assertEquals(2, count.get());
+        Assertions.assertEquals(1, rows.size());
+    }
+
+    private SentrySource source(Map<String, Object> options) {
+        return (SentrySource)
+                new SentrySourceFactory()
+                        .<SeaTunnelRow, SingleSplit, 
SingleSplitEnumeratorState>createSource(
+                                new TableSourceFactoryContext(
+                                        ReadonlyConfig.fromMap(options),
+                                        getClass().getClassLoader()))
+                        .createSource();
+    }
+
+    private AbstractSingleSplitReader<SeaTunnelRow> reader(
+            Map<String, Object> options, SourceReader.Context context) throws 
Exception {
+        AbstractSingleSplitReader<SeaTunnelRow> reader = 
source(options).createReader(context);
+        readers.add(reader);
+        reader.open();
+        return reader;
+    }
+
+    private Collector<SeaTunnelRow> collector(List<SeaTunnelRow> rows) throws 
Exception {
+        Collector<SeaTunnelRow> collector = mock(Collector.class);
+        when(collector.getCheckpointLock()).thenReturn(new Object());
+        doAnswer(
+                        invocation -> {
+                            rows.add(invocation.getArgument(0));
+                            return null;
+                        })
+                .when(collector)
+                .collect(any(SeaTunnelRow.class));
+        return collector;
+    }
+
+    private Map<String, Object> options() {
+        Map<String, Object> options = new HashMap<>();
+        options.put("token", "mock-token");
+        options.put("organization", "acme");
+        options.put("project", "demo");
+        options.put("start_time", "2026-01-01T00:00:00Z");
+        options.put("end_time", "2026-01-02T00:00:00Z");
+        options.put("parallelism", 1);
+        options.put("retry_delay_ms", 1);
+        options.put("page_size", 2);
+        return options;
+    }
+
+    private Map<String, Object> localOptions() {
+        Map<String, Object> options = options();
+        options.put("mock_mode", true);
+        options.put("api_base_url", origin());
+        return options;
+    }
+
+    private void start(com.sun.net.httpserver.HttpHandler handler) throws 
IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext(PATH, handler);
+        server.start();
+    }
+
+    private String origin() {
+        return "http://127.0.0.1:"; + server.getAddress().getPort();
+    }
+
+    private String link(boolean more, String cursor) {
+        return "<"
+                + origin()
+                + PATH
+                + "?cursor="
+                + cursor
+                + ">; rel=\"next\"; results=\""
+                + more
+                + "\"";
+    }
+
+    private static String event(String id) {
+        return "{\"eventID\":\""
+                + id
+                + 
"\",\"groupID\":\"42\",\"projectID\":\"7\",\"dateCreated\":\"2026-01-01T12:00:00Z\",\"title\":\"Résumé
 
✓\",\"message\":null,\"platform\":\"java\",\"value\":0.12345678901234567890123456789}";
+    }
+
+    private static void reply(HttpExchange exchange, int status, String body, 
String link)
+            throws IOException {
+        if (link != null) {
+            exchange.getResponseHeaders().add("Link", link);
+        }
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        exchange.sendResponseHeaders(status, bytes.length);
+        exchange.getResponseBody().write(bytes);
+        exchange.close();
+    }
+
+    private static void assertFailure(
+            org.junit.jupiter.api.function.Executable action, String message) {
+        SentryConnectorException error =
+                Assertions.assertThrows(SentryConnectorException.class, 
action);
+        Assertions.assertTrue(error.getMessage().contains(message), 
error.getMessage());
+    }
+}
diff --git a/seatunnel-dist/release-docs/LICENSE 
b/seatunnel-dist/release-docs/LICENSE
index 7f6ece4436..1a601c2b1d 100644
--- a/seatunnel-dist/release-docs/LICENSE
+++ b/seatunnel-dist/release-docs/LICENSE
@@ -488,3 +488,7 @@ The Google Analytics 4 connector also bundles these 
components:
 
 The existing CDDL entry for javax.annotation:javax.annotation-api:1.3.2 applies
 to this connector as well. Its license is retained in the connector JAR.
+
+Sentry source HTTP transport:
+     (Apache-2.0) httpclient (org.apache.httpcomponents:httpclient:4.5.13 - 
https://github.com/apache/httpcomponents-client)
+     (Apache-2.0) httpcore (org.apache.httpcomponents:httpcore:4.4.16 - 
https://hc.apache.org/)
diff --git a/seatunnel-dist/release-docs/NOTICE 
b/seatunnel-dist/release-docs/NOTICE
index 6675d77591..3b29d17841 100644
--- a/seatunnel-dist/release-docs/NOTICE
+++ b/seatunnel-dist/release-docs/NOTICE
@@ -869,6 +869,22 @@ The initial codebase was donated to the ASF by HugeGraph 
Authors, copyright 2017
  + Copyright 2012-2023 Aerospike, Inc.
  + Licensed under the Apache License, Version 2.0
 
+========================================================================
+Sentry source HTTP transport notices
+========================================================================
+
+Apache HttpClient
+Copyright 1999-2020 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+Apache HttpCore
+Copyright 2005-2022 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
 =========================================================================
 
  
+===============================================================================
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/pom.xml 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/pom.xml
new file mode 100644
index 0000000000..4c4012800a
--- /dev/null
+++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/pom.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+     http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
https://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.seatunnel</groupId>
+        <artifactId>seatunnel-connector-v2-e2e</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>connector-sentry-e2e</artifactId>
+    <name>SeaTunnel : E2E : Connector V2 : Sentry</name>
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.seatunnel</groupId>
+            <artifactId>seatunnel-core-starter</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.seatunnel</groupId>
+            <artifactId>connector-sentry</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.seatunnel</groupId>
+            <artifactId>connector-assert</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mock-server</groupId>
+            <artifactId>mockserver-netty-no-dependencies</artifactId>
+            <version>5.14.0</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryFixtureTest.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryFixtureTest.java
new file mode 100644
index 0000000000..b0fb306659
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryFixtureTest.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.e2e.connector.sentry;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockserver.configuration.Configuration;
+import org.mockserver.logging.MockServerLogger;
+import org.mockserver.matchers.HttpRequestPropertiesMatcher;
+
+class SentryFixtureTest {
+    @Test
+    void firstPageMatcherExcludesCursorRequests() {
+        HttpRequestPropertiesMatcher matcher =
+                new HttpRequestPropertiesMatcher(new Configuration(), new 
MockServerLogger());
+        matcher.apply(SentryIT.firstRequest());
+        Assertions.assertTrue(matcher.matches(null, SentryIT.request()));
+        Assertions.assertFalse(
+                matcher.matches(
+                        null, 
SentryIT.request().withQueryStringParameter("cursor", "0:2:0")));
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryIT.java
new file mode 100644
index 0000000000..ddff08d1fc
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryIT.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.e2e.connector.sentry;
+
+import org.apache.seatunnel.e2e.common.TestResource;
+import org.apache.seatunnel.e2e.common.TestSuiteBase;
+import org.apache.seatunnel.e2e.common.container.TestContainer;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.TestTemplate;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.matchers.Times;
+import org.mockserver.model.HttpRequest;
+import org.mockserver.model.HttpResponse;
+import org.mockserver.model.NottableOptionalString;
+import org.mockserver.model.NottableString;
+import org.mockserver.verify.VerificationTimes;
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+
+/** Engine integration with a deterministic API fixture, not a live Sentry 
deployment. */
+public class SentryIT extends TestSuiteBase implements TestResource {
+    private static final String ENDPOINT =
+            "http://sentry-fixture:1080/api/0/projects/acme/demo/events/";;
+    private GenericContainer<?> fixture;
+    private MockServerClient client;
+
+    @BeforeAll
+    @Override
+    public void startUp() {
+        fixture =
+                new 
GenericContainer<>(DockerImageName.parse("mockserver/mockserver:5.14.0"))
+                        .withNetwork(NETWORK)
+                        .withNetworkAliases("sentry-fixture")
+                        .withExposedPorts(1080)
+                        .withEnv("MOCKSERVER_LOG_LEVEL", "WARN")
+                        .waitingFor(Wait.forHttp("/").forStatusCode(404));
+        fixture.start();
+        client = new MockServerClient(fixture.getHost(), 
fixture.getMappedPort(1080));
+    }
+
+    @AfterAll
+    @Override
+    public void tearDown() {
+        try {
+            if (client != null) {
+                client.close();
+            }
+        } finally {
+            try {
+                if (fixture != null) {
+                    fixture.stop();
+                }
+            } finally {
+                NETWORK.close();
+            }
+        }
+    }
+
+    @TestTemplate
+    public void readsAllPagesAfterRateLimitThroughEngine(TestContainer 
container) throws Exception {
+        client.reset();
+        HttpRequest first = firstRequest();
+        HttpRequest second = request().withQueryStringParameter("cursor", 
"0:2:0");
+        client.when(first, Times.exactly(1))
+                .respond(
+                        
HttpResponse.response().withStatusCode(429).withHeader("Retry-After", "0"));
+        client.when(first).respond(response(true, "[" + event("a") + "," + 
event("b") + "]"));
+        client.when(second).respond(response(false, "[" + event("c") + "]"));
+        Container.ExecResult result = 
container.executeJob("/sentry_to_assert.conf");
+        Assertions.assertEquals(0, result.getExitCode(), result.getStderr());
+        client.verify(first, VerificationTimes.exactly(2));
+        client.verify(second, VerificationTimes.exactly(1));
+        client.verify(
+                
HttpRequest.request().withPath("/api/0/projects/acme/demo/events/"),
+                VerificationTimes.exactly(3));
+    }
+
+    static HttpRequest firstRequest() {
+        return request()
+                .withQueryStringParameter(
+                        NottableOptionalString.optional("cursor"), 
NottableString.not(".*"));
+    }
+
+    static HttpRequest request() {
+        return HttpRequest.request()
+                .withMethod("GET")
+                .withPath("/api/0/projects/acme/demo/events/")
+                .withHeader("Authorization", "Bearer mock-token")
+                .withQueryStringParameter("start", "2026-01-01T00:00:00Z")
+                .withQueryStringParameter("end", "2026-01-02T00:00:00Z")
+                .withQueryStringParameter("per_page", "2")
+                .withQueryStringParameter("full", "false")
+                .withQueryStringParameter("sample", "false");
+    }
+
+    private HttpResponse response(boolean more, String body) {
+        return HttpResponse.response()
+                .withHeader("Content-Type", "application/json")
+                .withHeader(
+                        "Link",
+                        "<" + ENDPOINT + "?cursor=0:2:0>; rel=\"next\"; 
results=\"" + more + "\"")
+                .withBody(body);
+    }
+
+    private String event(String id) {
+        return "{\"eventID\":\""
+                + id
+                + 
"\",\"groupID\":\"42\",\"projectID\":\"7\",\"dateCreated\":\"2026-01-01T12:00:00Z\",\"title\":\"Sentry
 event\",\"message\":null,\"platform\":\"java\"}";
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryMaskingTest.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryMaskingTest.java
new file mode 100644
index 0000000000..14d72ca6cf
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/java/org/apache/seatunnel/e2e/connector/sentry/SentryMaskingTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.e2e.connector.sentry;
+
+import org.apache.seatunnel.shade.com.typesafe.config.Config;
+
+import org.apache.seatunnel.core.starter.utils.ConfigBuilder;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Property;
+import org.apache.logging.log4j.core.impl.Log4jContextFactory;
+import org.apache.logging.log4j.core.layout.PatternLayout;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SentryMaskingTest {
+    @Test
+    void startupLogsMaskTokenWithoutChangingExecutableConfiguration() {
+        List<String> messages = new ArrayList<>();
+        AbstractAppender appender =
+                new AbstractAppender(
+                        "sentry-config-test",
+                        null,
+                        PatternLayout.createDefaultLayout(),
+                        true,
+                        Property.EMPTY_ARRAY) {
+                    @Override
+                    public void append(LogEvent event) {
+                        messages.add(event.getMessage().getFormattedMessage());
+                    }
+                };
+        // Initialize the actual SLF4J logging context before attaching the 
capture. Java 8 and 11
+        // can select different caller-classloader contexts in the shaded test 
classpath.
+        ConfigBuilder.of(Collections.emptyMap());
+        Set<LoggerContext> contexts =
+                new HashSet<>(
+                        ((Log4jContextFactory) LogManager.getFactory())
+                                .getSelector()
+                                .getLoggerContexts());
+        contexts.add((LoggerContext) LogManager.getContext(false));
+        Map<Logger, Level> loggers = new HashMap<>();
+        appender.start();
+        for (LoggerContext context : contexts) {
+            Logger logger = context.getLogger(ConfigBuilder.class.getName());
+            loggers.put(logger, logger.getLevel());
+            logger.addAppender(appender);
+            logger.setLevel(Level.INFO);
+        }
+        try {
+            Map<String, Object> source = Collections.singletonMap("token", 
"DO-NOT-LOG-SENTRY");
+            Config config =
+                    ConfigBuilder.of(
+                            Collections.singletonMap("source", 
Collections.singletonList(source)));
+            assertEquals(
+                    "DO-NOT-LOG-SENTRY", 
config.getConfigList("source").get(0).getString("token"));
+            assertTrue(
+                    messages.stream().anyMatch(message -> 
message.contains("Parsed config file")));
+            assertFalse(messages.toString().contains("DO-NOT-LOG-SENTRY"));
+            assertTrue(messages.toString().contains("******"));
+        } finally {
+            loggers.forEach(
+                    (logger, level) -> {
+                        logger.removeAppender(appender);
+                        logger.setLevel(level);
+                    });
+            appender.stop();
+        }
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/resources/logback-test.xml
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/resources/logback-test.xml
new file mode 100644
index 0000000000..fa971259e4
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/resources/logback-test.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+     http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<configuration>
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} %-5level %logger - %msg%n</pattern>
+        </encoder>
+    </appender>
+    <logger name="com.github.dockerjava" level="WARN" />
+    <logger name="org.mockserver" level="WARN" />
+    <root level="INFO">
+        <appender-ref ref="CONSOLE" />
+    </root>
+</configuration>
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/resources/sentry_to_assert.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/resources/sentry_to_assert.conf
new file mode 100644
index 0000000000..2c68eb6fc2
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-sentry-e2e/src/test/resources/sentry_to_assert.conf
@@ -0,0 +1,53 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+env { parallelism = 1, job.mode = "BATCH" }
+source {
+  Sentry {
+    plugin_output = "events"
+    token = "mock-token"
+    mock_mode = true
+    api_base_url = "http://sentry-fixture:1080";
+    organization = "acme"
+    project = "demo"
+    start_time = "2026-01-01T00:00:00Z"
+    end_time = "2026-01-02T00:00:00Z"
+    page_size = 2
+    retry_delay_ms = 1
+  }
+}
+sink {
+  Assert {
+    plugin_input = "events"
+    rules {
+      row_rules = [
+        {rule_type = MIN_ROW, rule_value = 3},
+        {rule_type = MAX_ROW, rule_value = 3}
+      ]
+      field_rules = [
+        {field_name = event_id, field_type = string, field_value = [{rule_type 
= NOT_NULL}]},
+        {field_name = group_id, field_type = string, field_value = [{rule_type 
= NOT_NULL, equals_to = "42"}]},
+        {field_name = project_id, field_type = string, field_value = 
[{rule_type = NOT_NULL, equals_to = "7"}]},
+        {field_name = date_created, field_type = string, field_value = 
[{rule_type = NOT_NULL, equals_to = "2026-01-01T12:00:00Z"}]},
+        {field_name = title, field_type = string, field_value = [{rule_type = 
NOT_NULL, equals_to = "Sentry event"}]},
+        {field_name = message, field_type = string, field_value = [{rule_type 
= NULL}]},
+        {field_name = platform, field_type = string, field_value = [{rule_type 
= NOT_NULL, equals_to = "java"}]},
+        {field_name = content, field_type = string, field_value = [{rule_type 
= NOT_NULL}]}
+      ]
+    }
+  }
+}
diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml
index 8e41797e13..b42a2f3ce9 100644
--- a/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml
+++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml
@@ -96,6 +96,7 @@
         <module>connector-milvus-e2e</module>
         <module>connector-activemq-e2e</module>
         <module>connector-prometheus-e2e</module>
+        <module>connector-sentry-e2e</module>
         <module>connector-qdrant-e2e</module>
         <module>connector-sls-e2e</module>
         <module>connector-socket-e2e</module>

Reply via email to