如何用Webfunny APM采集上报后端数据

一只会飞的鱼儿 1小时前 ⋅ 6 阅读
ad

一、依赖配置

已在 `pom.xml` 中添加以下opentelemetry相关依赖:

<!-- OpenTelemetry API -->
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-api</artifactId>
    <version>1.24.0</version>
</dependency>

<!-- OpenTelemetry 注解支持 -->
<dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-instrumentation-annotations</artifactId>
    <version>1.24.0</version>
</dependency>

<!-- OpenTelemetry SDK -->
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-sdk</artifactId>
    <version>1.24.0</version>
</dependency>

<!-- OpenTelemetry OTLP Exporter -->
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
    <version>1.24.0</version>
</dependency>

二、应用配置

使用方式

1. Controller 层监控

在 Controller 方法上使用 `@Trace` 注解或 `@WithSpan` 注解:

package com.webfunny.member.controller;

import com.webfunny.common.annotation.Trace;
import com.webfunny.common.entity.FebsResponse;
import com.webfunny.member.entity.Member;
import com.webfunny.member.service.IMemberService;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("member")
public class MemberController {

    @Autowired
    private IMemberService memberService;

    /**
     * 方式一:使用自定义 @Trace 注解(推荐)
     * 会自动采集 HTTP 请求信息:URL、方法、IP、User-Agent 等
     */
    @GetMapping("/{id}")
    @Trace(value = "getMemberById", kind = "controller")
    public FebsResponse getMemberById(@PathVariable Long id) {
        Member member = memberService.findById(id);
        return new FebsResponse().success().data(member);
    }

    /**
     * 方式二:使用 OpenTelemetry 原生 @WithSpan 注解
     */
    @PostMapping("/save")
    @WithSpan("saveMember")
    public FebsResponse saveMember(@RequestBody Member member) {
        memberService.save(member);
        return new FebsResponse().success();
    }
}

`@Trace` 自定义注解

/**
 * OpenTelemetry 追踪注解
 * 用于标记需要进行链路追踪的方法
 * 
 * 使用方式:
 * 1. 在 Controller 方法上使用,追踪 HTTP 请求
 * 2. 在 Service 方法上使用,追踪业务逻辑
 * 3. 在 Mapper 方法上使用,追踪数据库查询(通常 SQL 会自动被拦截器追踪)
 * 
 * @author yulei
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Trace {
    
    /**
     * Span 名称,如果不指定则使用方法名
     */
    String value() default "";
    
    /**
     * Span 类型(可选)
     * 例如:controller、service、dao 等
     */
    String kind() default "";
}

2. Service 层监控

在 Service 方法上使用注解:

package com.webfunny.member.service.impl;

import com.webfunny.common.annotation.Trace;
import com.webfunny.member.entity.Member;
import com.webfunny.member.mapper.MemberMapper;
import com.webfunny.member.service.IMemberService;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class MemberServiceImpl implements IMemberService {

    @Autowired
    private MemberMapper memberMapper;

    /**
     * 使用 @Trace 注解监控 Service 方法
     * 会记录方法执行时间、参数等
     */
    @Override
    @Trace(value = "findMemberById", kind = "service")
    public Member findById(Long id) {
        // 业务逻辑
        Member member = memberMapper.selectById(id);
        
        // 可以添加额外的业务处理
        if (member != null) {
            // 处理逻辑
        }
        
        return member;
    }

    /**
     * 使用 @WithSpan 注解
     */
    @Override
    @WithSpan("saveMemberService")
    public void save(Member member) {
        memberMapper.insert(member);
    }

    /**
     * 复杂业务场景,多个数据库操作
     */
    @Override
    @Trace(value = "updateMemberWithOrders", kind = "service")
    public void updateMemberWithOrders(Long memberId, List<Order> orders) {
        // 更新会员信息
        Member member = memberMapper.selectById(memberId);
        member.setUpdateTime(new Date());
        memberMapper.updateById(member);
        
        // 更新订单信息(这些 SQL 也会被自动追踪)
        for (Order order : orders) {
            orderMapper.updateById(order);
        }
    }
}

3. DAO/Mapper 层监控

**重要:** Mapper 层的 SQL 会被 `SqlTraceInterceptor` 自动拦截和追踪,**不需要手动添加注解**。

SqlTraceInterceptor:

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.springframework.stereotype.Component;

import java.sql.Connection;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Properties;

/**
 * MyBatis SQL 追踪拦截器
 * 用于捕获 SQL 执行信息并上报到 OpenTelemetry
 * 
 * @author yulei
 */
@Slf4j
@Component
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SqlTraceInterceptor implements Interceptor {

    private static final Tracer tracer = GlobalOpenTelemetry.getTracer("mybatis-sql-tracer");

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
        
        // 获取 MappedStatement
        MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
        String sqlId = mappedStatement.getId();
        
        // 获取 BoundSql
        BoundSql boundSql = statementHandler.getBoundSql();
        String sql = boundSql.getSql();
        
        // 获取参数对象
        Object parameterObject = boundSql.getParameterObject();
        
        // 创建 span
        Span span = tracer.spanBuilder("sql.query")
                .setAttribute("db.system", "mysql")
                .setAttribute("db.operation", getSqlOperation(sql))
                .setAttribute("db.statement", formatSql(sql))
                .setAttribute("db.sql.id", sqlId)
                .startSpan();
        
        try (Scope scope = span.makeCurrent()) {
            // 添加格式化后的 SQL(带参数)
            String formattedSql = showSql(mappedStatement.getConfiguration(), boundSql);
            span.setAttribute("db.statement.formatted", formattedSql);
            
            long startTime = System.currentTimeMillis();
            
            // 执行 SQL
            Object result = invocation.proceed();
            
            long endTime = System.currentTimeMillis();
            long duration = endTime - startTime;
            
            // 记录执行时间
            span.setAttribute("db.execution.time.ms", duration);
            
            // 如果执行时间超过阈值,记录警告
            if (duration > 1000) {
                span.setAttribute("db.slow.query", true);
                log.warn("慢查询检测 - 执行时间: {}ms, SQL: {}", duration, formattedSql);
            }
            
            span.setStatus(StatusCode.OK);
            return result;
            
        } catch (Exception e) {
            span.recordException(e);
            span.setStatus(StatusCode.ERROR, "SQL execution failed: " + e.getMessage());
            throw e;
        } finally {
            span.end();
        }
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // 可以从配置文件读取配置
    }

    /**
     * 获取 SQL 操作类型
     */
    private String getSqlOperation(String sql) {
        if (sql == null || sql.isEmpty()) {
            return "UNKNOWN";
        }
        String upperSql = sql.trim().toUpperCase();
        if (upperSql.startsWith("SELECT")) {
            return "SELECT";
        } else if (upperSql.startsWith("INSERT")) {
            return "INSERT";
        } else if (upperSql.startsWith("UPDATE")) {
            return "UPDATE";
        } else if (upperSql.startsWith("DELETE")) {
            return "DELETE";
        }
        return "UNKNOWN";
    }

    /**
     * 格式化 SQL(移除多余空白)
     */
    private String formatSql(String sql) {
        if (sql == null) {
            return "";
        }
        return sql.replaceAll("\\s+", " ").trim();
    }

    /**
     * 显示完整的 SQL(包含参数值)
     */
    private String showSql(Configuration configuration, BoundSql boundSql) {
        try {
            Object parameterObject = boundSql.getParameterObject();
            List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
            String sql = boundSql.getSql().replaceAll("\\s+", " ");
            
            if (parameterMappings.size() > 0 && parameterObject != null) {
                TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
                if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                    sql = sql.replaceFirst("\\?", getParameterValue(parameterObject));
                } else {
                    MetaObject metaObject = configuration.newMetaObject(parameterObject);
                    for (ParameterMapping parameterMapping : parameterMappings) {
                        String propertyName = parameterMapping.getProperty();
                        if (metaObject.hasGetter(propertyName)) {
                            Object obj = metaObject.getValue(propertyName);
                            sql = sql.replaceFirst("\\?", getParameterValue(obj));
                        } else if (boundSql.hasAdditionalParameter(propertyName)) {
                            Object obj = boundSql.getAdditionalParameter(propertyName);
                            sql = sql.replaceFirst("\\?", getParameterValue(obj));
                        }
                    }
                }
            }
            return sql;
        } catch (Exception e) {
            log.error("格式化 SQL 失败", e);
            return boundSql.getSql();
        }
    }

    /**
     * 获取参数值的字符串表示
     */
    private String getParameterValue(Object obj) {
        String value;
        if (obj instanceof String) {
            value = "'" + obj + "'";
        } else if (obj instanceof Date) {
            DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.CHINA);
            value = "'" + formatter.format(new Date()) + "'";
        } else {
            if (obj != null) {
                value = obj.toString();
            } else {
                value = "null";
            }
        }
        return value;
    }
}

但如果有复杂的自定义方法,也可以添加注解:

package com.webfunny.member.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.webfunny.member.entity.Member;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface MemberMapper extends BaseMapper<Member> {

    /**
     * 普通方法,SQL 会被自动追踪,无需添加注解
     */
    List<Member> findActiveMembers();

    /**
     * 复杂查询方法,可以添加注解以便更好地标识
     */
    @WithSpan("findMembersByCondition")
    List<Member> findByCondition(@Param("condition") MemberCondition condition);
}

三、启动脚本

去opentelemetry官网下载opentelemetry-javaagent探针

idea中添加add VM options:

-javaagent:/Users/yulei/Documents/package/opentelemetry/opentelemetry-javaagent.jar
-Dotel.service.name=webfunny-manage
-Dotel.resource.attributes=deployment.environment=webfunny_20251129_230724_sit,service.instance.id=webfunny_20251129_230724_sit,service.version=2.0
-Dotel.exporter.otlp.endpoint=http://xxx.webfunny.cn:4317
-Dotel.exporter.otlp.protocol=grpc
-Dotel.exporter.otlp.timeout=10s
-Dotel.traces.exporter=otlp
-Dotel.metrics.exporter=none
-Dotel.logs.exporter=none
-Dotel.javaagent.logging.level=INFO
-Dotel.instrumentation.jvm-metrics.enabled=false
-Dotel.instrumentation.system-metrics.enabled=false
-Dotel.instrumentation.jdbc.enabled=true
-Dotel.instrumentation.spring-webmvc.enabled=true
-Dotel.instrumentation.http-client.enabled=true
-Dotel.instrumentation.spring-webmvc.capture-request-parameters=true
-Dotel.instrumentation.jdbc.statement-sanitizer.enabled=true

应用脚本:

#!/bin/bash

# 应用名称
prog="webfunny-manage"

# OpenTelemetry 配置
OTEL_SERVICE_NAME="webfunny-manage-生产环境"
OTEL_RESOURCE_ATTRIBUTES="deployment.environment=webfunny_20251129_230724_pro,service.instance.id=webfunny_20251129_230724_pro"
OTEL_EXPORTER_OTLP_ENDPOINT="http://xxx.webfunny.cn:4317"
OTEL_EXPORTER_OTLP_PROTOCOL="grpc"
OTEL_EXPORTER_OTLP_TIMEOUT="10s"
OTEL_TRACES_EXPORTER="otlp"
OTEL_METRICS_EXPORTER="none"
OTEL_LOGS_EXPORTER="otlp"
OTEL_JAVAAGENT_LOGGING_LEVEL="INFO"
OTEL_INSTRUMENTATION_JVM_METRICS_ENABLED="true"
OTEL_INSTRUMENTATION_SYSTEM_METRICS_ENABLED="true"
OTEL_INSTRUMENTATION_JDBC_ENABLED="true"
OTEL_INSTRUMENTATION_SPRING_WEBMVC_ENABLED="true"
OTEL_INSTRUMENTATION_HTTP_CLIENT_ENABLED="true"
OTEL_INSTRUMENTATION_SPRING_WEBMVC_CAPTURE_REQUESR_PARAMETERS="true"
OTEL_INSTRUMENTATION_JDBC_STATEMENT_SANITIZER_ENABLED="true"
# Java 代理路径
OTEL_AGENT_JAR="/home/yulei/manage/opentelemetry-agent/opentelemetry-javaagent.jar"

# 执行启动命令
    exec java \
        -javaagent:"$OTEL_AGENT_JAR" \
        -Dotel.service.name="$OTEL_SERVICE_NAME" \
        -Dotel.resource.attributes="$OTEL_RESOURCE_ATTRIBUTES" \
        -Dotel.exporter.otlp.endpoint="$OTEL_EXPORTER_OTLP_ENDPOINT" \
        -Dotel.exporter.otlp.protocol="$OTEL_EXPORTER_OTLP_PROTOCOL" \
        -Dotel.exporter.otlp.timeout="$OTEL_EXPORTER_OTLP_TIMEOUT" \
        -Dotel.traces.exporter="$OTEL_TRACES_EXPORTER" \
        -Dotel.metrics.exporter="$OTEL_METRICS_EXPORTER" \
        -Dotel.logs.exporter="$OTEL_LOGS_EXPORTER" \
        -Dotel.javaagent.logging.level="$OTEL_JAVAAGENT_LOGGING_LEVEL" \
        -Dotel.instrumentation.jvm-metrics.enabled="$OTEL_INSTRUMENTATION_JVM_METRICS_ENABLED" \
        -Dotel.instrumentation.system-metrics.enabled="$OTEL_INSTRUMENTATION_SYSTEM_METRICS_ENABLED" \
        -Dotel.instrumentation.jdbc.enabled="$OTEL_INSTRUMENTATION_JDBC_ENABLED" \
        -Dotel.instrumentation.spring-webmvc.enabled="$OTEL_INSTRUMENTATION_SPRING_WEBMVC_ENABLED" \
        -Dotel.instrumentation.http-client.enabled="$OTEL_INSTRUMENTATION_HTTP_CLIENT_ENABLED" \
        -Dotel.instrumentation.spring-webmvc.capture-request-parameters="$OTEL_INSTRUMENTATION_SPRING_WEBMVC_CAPTURE_REQUESR_PARAMETERS" \
        -Dotel.instrumentation.jdbc.statement-sanitizer.enabled="$OTEL_INSTRUMENTATION_JDBC_STATEMENT_SANITIZER_ENABLED" \
        -jar -Xms256M -Xmx256M \
        webfunny_manage-2.0.jar >/dev/null 2>&1 &

四、监控信息采集

自动采集的信息

1. HTTP 请求信息(Controller 层)

- `http.method`: HTTP 方法(GET、POST 等)
- `http.url`: 请求 URL
- `http.client_ip`: 客户端 IP
- `http.user_agent`: User-Agent
- `method.name`: Java 方法名
- `class.name`: Java 类名
- `execution.time.ms`: 方法执行时间(毫秒)

2. SQL 执行信息(DAO 层)
- `db.system`: 数据库类型(mysql)
- `db.operation`: SQL 操作类型(SELECT、INSERT、UPDATE、DELETE)
- `db.statement`: 原始 SQL 语句
- `db.statement.formatted`: 格式化的 SQL(包含参数值)
- `db.sql.id`: MyBatis Mapper 方法全限定名
- `db.execution.time.ms`: SQL 执行时间(毫秒)
- `db.slow.query`: 是否为慢查询(执行时间 > 1000ms)

3. 方法调用信息(Service 层)
- `method.name`: 方法名
- `class.name`: 类名
- `span.kind`: Span 类型(service、controller 等)
- `execution.time.ms`: 执行时间
- `arg.0, arg.1, ...`: 方法参数(仅基本类型)

4. 异常信息(所有层)
- 异常堆栈信息
- 异常消息
- Span 状态设置为 ERROR

生成的追踪链路结构

Trace ID: 1234567890abcdef
├─ Span 1: Controller.getMemberDetail (150ms)
│   ├─ http.method: GET
│   ├─ http.url: http://localhost:8080/member/123
│   └─ http.client_ip: 192.168.1.100
│
│  └─ Span 2: Service.findMemberWithOrders (140ms)
│      ├─ method.name: findMemberWithOrders
│      └─ span.kind: service
│
│     ├─ Span 3: sql.query (50ms)
│     │   ├─ db.operation: SELECT
│     │   ├─ db.statement: SELECT * FROM member WHERE id = ?
│     │   └─ db.statement.formatted: SELECT * FROM member WHERE id = 123
│     │
│     └─ Span 4: sql.query (80ms)
│         ├─ db.operation: SELECT
│         ├─ db.statement: SELECT * FROM orders WHERE member_id = ?
│         └─ db.statement.formatted: SELECT * FROM orders WHERE member_id = 123

展开全部链路如下:

错误异常上报:

空指针等异常详细信息:

## 注意事项

1. **性能影响**:链路追踪会有轻微的性能开销(一般 < 5%),生产环境建议配置采样率
2. **SQL 敏感信息**:SQL 参数可能包含敏感信息,注意数据安全
3. **注解使用**:
- Controller 和 Service 层建议使用 `@Trace` 或 `@WithSpan`
- Mapper 层 SQL 会自动追踪,通常不需要额外注解
4. **异常处理**:所有异常都会被自动记录到 Span 中

五、常见问题

### Q1: 为什么看不到追踪数据?
A: 检查以下几点:
- OpenTelemetry 配置是否正确
- OTLP 接收器(Jaeger/Tempo)是否正常运行
- 应用配置 `opentelemetry.enabled` 是否为 `true`

### Q2: SQL 信息没有被采集?
A: 确认 `SqlTraceInterceptor` 已注册为 Spring Bean,检查 `MybatisPlusConfigure` 配置

六、总结

通过 OpenTelemetry 集成,项目可以实现:
1. Controller 层自动采集 HTTP 请求信息
2. Service 层自动追踪业务方法调用
3. DAO 层自动采集 SQL 执行信息和参数
4. 完整的调用链路追踪
5. 慢查询自动检测
6. 异常信息自动记录

只需在关键方法上添加 `@Trace` 或 `@WithSpan` 注解,即可实现全链路监控!

 

关于Webfunny

Webfunny专注于前端监控系统,前端埋点系统的研发。 致力于帮助开发者快速定位问题,帮助企业用数据驱动业务,实现业务数据的快速增长。支持H5/Web/PC前端、微信小程序、支付宝小程序、UniApp和Taro等跨平台框架。实时监控前端网页、前端数据分析、错误统计分析监控和BUG预警,第一时间报警,快速修复BUG!支持私有化部署,Docker容器化部署,可支持千万级PV的日活量!

  点赞 0   收藏 0
  • 一只会飞的鱼儿
    共发布64篇文章 获得8个收藏
全部评论: 0