MASA MAUI APP前端监控指南

发布时间 2023-11-30 15:31:25作者: MASA技术团队

MAUI Blazor 接入到 OpenTelemetry

近期由于我们APP项目(MAUI+Masa Blazor),需要做运营数据采集埋点,经过综合考虑后,决定采用接入OpenTelemetry SDK的方式,由于目前OpenTelemetry的可测性大部分都是基于后端api的,所以我们也对MAUI Blazor进行接入进行了一番的研究和尝试。

开发工具和环境

  • 开发工具 Visual Studio 2022 Preview (17.8.0 )
  • MAUI 版本:net7.0-ios;net7.0-android
  • .NET Core版本:6.0
  • otel SDK 版本:1.5.1

OpenTelemetry SDK接入过程

  1. MAUI 项目安装OpenTelemetry依赖包:
<PackageReference Include="OpenTelemetry" Version="1.5.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.5.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol.Logs" Version="1.5.0-rc.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.5.1" />		
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.5.0-beta.1" />
  1. 注入OpenTelemetry SDK:
    由于当前的OpenTelemetry SDK接入都是针对后端api接口,而MAUI和Blazor前端没有对应的实现,所以需要我们首先需要自定义一个追踪的ActivitySource,然后在相应需要追踪的上下文中使用ActivitySourceActivity进行管理。
//注入全局的 Maui ActivitySource
builder.Services.AddSingleton(new ActivitySource("MAUI"));

//构建OpenTelemetry的tracerProvider
var tracerProvider = Sdk.CreateTracerProviderBuilder()
.ConfigureResource(resource =>
{
    resource.AddService(AppInfo.PackageName, //包名
    AppInfo.Current.Name, //应用名称
    AppInfo.Current.VersionString, //app版本号
    serviceInstanceId: DeviceInfo.Current.Name.ToString()); //设备名称作为instanceId
    resource.AddAttributes(new Dictionary<string, object> {                
        {"device_type", DeviceInfo.Current.DeviceType },//设备类型,物理或虚拟机
        {"device_platform",DeviceInfo.Current.Platform},//设备系统类型,andriod 、ios
        {"device_version",DeviceInfo.Current.Version},// andriod或ios 版本号
        {"device_model",DeviceInfo.Current.Model},//设备型号,不同厂商的手机型号唯一表示
        {"device_manufacturer",DeviceInfo.Current.Manufacturer},//手机厂商
        {"device_idiom",DeviceInfo.Current.Idiom}//终端类型 phone,tv或平板等
    });
})
.AddOtlpExporter(otlp => otlp.Endpoint = new Uri("http://localhost:4317"))
// 把 Maui ActivitySource 添加到OpenTelemetry的追踪源中
.AddSource("MAUI")
.Build();

services.AddSingleton(tracerProvider);
  1. 进行Log的监测:
var resources = ResourceBuilder.CreateDefault().AddService(AppInfo.PackageName, //包名
AppInfo.Current.Name, //应用名称
AppInfo.Current.VersionString, //app版本号
serviceInstanceId: DeviceInfo.Current.Name.ToString()); //设备名称作为instanceId
resources.AddAttributes(new Dictionary<string, object> {
        {"device_type", DeviceInfo.Current.DeviceType },//设备类型,物理或虚拟机
        {"device_platform",DeviceInfo.Current.Platform},//设备系统类型,andriod 、ios
        {"device_version",DeviceInfo.Current.Version},// andriod或ios 版本号
        {"device_model",DeviceInfo.Current.Model},//设备型号,不同厂商的手机型号唯一表示
        {"device_manufacturer",DeviceInfo.Current.Manufacturer},//手机厂商
        {"device_idiom",DeviceInfo.Current.Idiom}//终端类型 phone,tv或平板等
    });
builder.Logging.AddMasaOpenTelemetry(builder =>
{
    builder.SetResourceBuilder(resources);
    builder.AddOtlpExporter(otlp => otlp.Endpoint = new Uri("http://localhost:4317"));
}).SetMinimumLevel(//开发环境记录所有的日志,生产环境只记录错误的日志
#if RELEASE
            LogLevel.Error
#else
            LogLevel.Information
#endif
  1. MAUI的Webview内核UserAgent
    在主MAUI页面MainPage.xmal添加事件BlazorWebViewInitialized:
<BlazorWebView HostPage="wwwroot/index.html" 
  BlazorWebViewInitialized="BlazorWebView_BlazorWebViewInitialized">
    <BlazorWebView.RootComponents>
        <RootComponent Selector="#app" ComponentType="{x:Type blazor:Main}" />
    </BlazorWebView.RootComponents>
</BlazorWebView>

private void BlazorWebView_BlazorWebViewInitialized(object sender, 
  Microsoft.AspNetCore.Components.WebView.BlazorWebViewInitializedEventArgs e)
{
    #if ANDROID
    //IPhoneService 为我们构建的ios和android的统一设备设备相关服务
    var phoneService= MauiApplication.Current.Services.GetRequiredService<IPhoneService>();
    phoneService.SetUserAgent(e.WebView.Settings.UserAgentString);
    #endif
}

Blazor页面的对OpenTelemetry的支持

因为Blazor页面和组件有固有的生命周期,所以我们的想法是在生命周期内对Blazor页面和组件进行统一的处理,所以我们构建了当前项目的blazor组件基类 MyCompontentBase,要求所有组件必须继承该类,主要代码:

public abstract partial class MyCompontentBase : IDisposable, IHandleEvent
{
    //基类的logger对象,做日志打印
    [Inject]
    public ILogger Logger { get; set; }

    //前面注入的MAUI ActivitySource实例
    [Inject]
    public ActivitySource activitySource { get; set; }

    #region 事件监听
    //blazor组件事件的委托处理者,用户查找组件类型和相应事件触发的执行方法名称
    private static FieldInfo _delegate = typeof(EventCallbackWorkItem)
                                          .GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
                                          .FirstOrDefault(p => p.Name == "_delegate");

    private CancellationTokenSource _cancellationTokenSource;
    
    // 重写blazor 基类Microsoft.AspNetCore.Components.ComponentBase IHandleEvent的接口
    // 监听所有组件的click事件和监测处理过程中出现的异常信息
    async Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
    {
        if (arg is MouseEventArgs mouseEvent && mouseEvent.Type == "click" && _delegate != null)
        {
            var handler = (MulticastDelegate)_delegate.GetValue(callback)!;
            var url = _activity?.GetTagItem("client.path");
            var title = _activity?.GetTagItem("client.title");
            //https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/semantic_conventions/events.md
            Logger.LogInformation("{client.path} {client.title} {event.source.handler} is {event.name}", 
                url, title, handler.Method.Name, mouseEvent.Type);
            //事件触发的组件类型全名称
            _activity?.SetTag("event.source.type", handler.Target.GetType().FullName);
            //事件触发方法的名称,如果为() => {}这类匿名委托方法,这边的记录就没有意义,会生成一个随机的event名称
            _activity?.SetTag("event.source.handler", handler.Method.Name);

            try
            {
                _cancellationTokenSource?.Cancel();
                _cancellationTokenSource = new CancellationTokenSource();

                await Task.Delay(300, _cancellationTokenSource.Token);
                Loading = true;
                await CallBackInvoke();
            }
            catch (TaskCanceledException)
            {

            }
            finally
            {
                Loading = false;
            }
        }
        else
        {
            await CallBackInvoke();
        }

        async Task CallBackInvoke()
        {
            var task = callback.InvokeAsync(arg);
            var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
                                  task.Status != TaskStatus.Canceled;

            if (AfterHandleEventShouldRender())
            {
                StateHasChanged();
            }

            await (shouldAwaitTask
                ? CallStateHasChangedOnAsyncCompletion(task, _activity)
                : Task.CompletedTask);
        }
    }


    //事件执行异常时,将异常信息打印到错误日志
    private async Task CallStateHasChangedOnAsyncCompletion(Task task, Activity activity)
    {
        try
        {
            await task;
        }
        catch (Exception ex) // avoiding exception filters for AOT runtime support
        {
            // Ignore exceptions from task cancellations, but don't bother issuing a state change.
            if (task.IsCanceled)
            {
                return;
            }

            activity?.SetTag("exception.message", ex.Message);
            //如果已经有默认的异常处理,则交给相应的异常处理程序进行处理,否则才打印错误日志
            if (ErrorHandler != null)
            {
                await ErrorHandler.HandleExceptionAsync(ex);
            }
            else
            {
                Logger.LogError(ex, "Compontent execute error , message: {meesage}", ex.Message);
                throw;
            }
        }
        //activity?.Stop();

        if (AfterHandleEventShouldRender())
        {
            StateHasChanged();
        }
    }
    #endregion

    #region 跳转监听
    //监听url地址发生更改时的时间,记录将要跳转的url页面    
    private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
    {
        var url = NavigationManager.Uri.Replace(NavigationManager.BaseUri, "/");
        Current?.SetTag("to.path", HttpUtility.UrlDecode(url));
    }
    #endregion

    public long UnixTimespan(DateTime time)
    {
        DateTimeOffset offset = new(time.ToLocalTime());
        return offset.ToUnixTimeMilliseconds();
    }

    /*
     * 当前页面的Activity实例,由于当前只能显示某一个页面,所以为静态对象;
     * 额外把Activity.Current进行同步,利于其它地方的追踪上下文管理。
     */
    public static Activity Current
    {
        get
        {
            return _current;
        }

        set
        {
            _current = value;
            Activity.Current = _current;
        }
    }

    private static Activity _current;

    private static Activity _activity;

    /*
     * 检查当前组件是否页面,
     * 如果页面有RouteAttribute属性,就为页面,否则为组件;
     * 如果是页面会返回页面路由,路由目前是根据路由的参数个数进行匹配,可能不太严谨
     */
    private bool IsPage(out string? routeTemplate)
    {
        routeTemplate = null;
        var routes = GetType().GetCustomAttributes<RouteAttribute>().ToList();
        if (!routes.Any())
            return false;
        if (routes.Count == 1)
            routeTemplate = routes.First().Template;
        else
        {
            var count = NavigationManager.Uri
                            .Replace(NavigationManager.BaseUri, "/").Split('/').Length;
            //根据路由的参数个数进行匹配
            routeTemplate = routes.FirstOrDefault(route => route.Template.Split('/').Length - count == 0)?.Template;
        }
        return true;
    }
    
    //blazor生命周期的第一个执行方法,初始化Activity
    protected override void OnInitialized()
    {
        if (IsPage(out var routeTemplate))
        {
            _activity = StartPageActivity();
            //页面路由,当前我们采用了
            _activity?.SetTag("client.path.route", routeTemplate);
        }
        else
        {
            //HeadToolbar 为我们项目的标题组件,在此获取页面的标题,并写入到Activity
            if (this.GetType() == typeof(HeadToolbar))
            {
                var title = ((HeadToolbar)this).Value;
                _activity?.SetTag("client.title", title);
            }
        }
        //添加url变化监听事件
        NavigationManager.LocationChanged += OnLocationChanged;
        //调用blazor基类的OnInitialized生命周期方法
        base.OnInitialized();
    }

    /*
     * 组件的Activity开始创建的方法,
     * 如果是有上个页面的记录,就将来源页面的标题、url地址和触发的事件的方法名记录下来,
     * 可以追溯从哪个页面的哪个点击,进入到了当前页面
     */
    protected Activity StartPageActivity()
    {
        _activity = activitySource.StartActivity(GetType().Name, ActivityKind.Client);
        if (Current != null && Current != _activity)
        {
            //跳转来源页面的url路径
            _activity?.SetTag("from.path", Current.GetTagItem("client.path"));
            //跳转来源页面的标题
            _activity?.SetTag("from.title", Current.GetTagItem("client.title"));
            //跳转来源页面的点击触发方法名称
            _activity?.SetTag("from.event.source.handler", Current.GetTagItem("event.source.handler"));
            if (string.IsNullOrEmpty(_activity?.ParentId))
                _activity?.SetParentId(Current.Id);
        }
        Current = _activity;
        //客户端类型,做数据筛选可以区分出来是maui blazor的数据
        _activity?.SetTag("client.type", "maui-blazor");
        //userAgent, 如果客户端有特别的问题,可以进行兼容性的排查的信息
        _activity?.SetTag("client.user_agent", PhoneService.UserAgent);

        var url = NavigationManager.Uri.Replace(NavigationManager.BaseUri, "/");
        //当前页面的url地址
        _activity?.SetTag("client.path", HttpUtility.UrlDecode(url));

        return _activity;
    }    
    
    
    protected override async Task OnInitializedAsync()
    {   //我们项目的用户信息会缓存在客户端,在页面加载完成后,异步调用, 可根据项目实际进行适当的调整
        //var user = await LocalStorgeService.GetUserInfoAsync();
        //_activity?.SetTag("enduser.id", user?.Id);
        await base.OnInitializedAsync();
    }
    
    //页面首次加载时,记录页面首次显示时间,可以来观察页面初始化所花费的时间
    protected override void OnAfterRender(bool firstRender)
    {
        if (firstRender)
        {
            _activity?.SetTag("client.show.startTime", UnixTimespan(DateTime.Now));
        }
        base.OnAfterRender(firstRender);
    }
    
    //页面销毁时,结束追踪,框架会自动上报追踪数据
    protected override void Dispose(bool disposing)
    {
        EndPageActivity();
        NavigationManager.LocationChanged -= OnLocationChanged;
        base.Dispose(disposing);
    }

    private void EndPageActivity()
    {
        _activity?.Stop();
    }
}

我们在Blazor的几个生命周期方法内进行追踪对象Activity的管理:

  1. OnInitialized 进行 当前页面或组件的Activity对象的创建和初始化;
  2. OnInitializedAsync 在页面首次加载完成后,从ILocalStorage获取当前已经登录的用户Id;
  3. OnAfterRender 页面首次渲染完成后,记录页面的首次显示时间;
  4. Dispose 销毁该组件对象时,销毁Activity对象,并自动执行数据上报。

Blazor使用

Blazor页面和组件使用时,必须继承MyCompontentBase,相应的生命周期方法内,必须调用base.对应的生命周期方法;如果有特别的需求,需要向Activity中写入额外的Tag,直接调用Activity.Currrent?.SetTag("tag1","tag1value")即可,如果要打印日志,直接调用Logger.LogInformation("日志内容"),相应的日志和Activity就会被OpenTelemetry SDK自动管理起来

问题

  1. OTEL 默认上报采用Grpc协议,如果部署的OTEL为内网,采用的IP地址加端口,在Andriod 9.0及以上是可以使用的,8.0及以下还需要验证;如果使用了域名和https的方式,则只能在Andriod 10.0及以上版本使用;
  2. 如果使用的是HttpProtobuf协议,则只能在Andriod 10.0及以上版本使用,在Andriod 9.0以内因为HttpClient.Send方法当前存在问题,参考原因,如果想要支持Andriod 9.0及以下版本,可以手动下载OpenTelemetry对象的发布版本源码,修改类BaseOtlpHttpExportClient的方法SendHttpRequest:
protected HttpResponseMessage SendHttpRequest(HttpRequestMessage request, 
    CancellationToken cancellationToken)
{
    return this.HttpClient.SendAsync(request, cancellationToken)
                            .GetAwaiter().GetResult();
}

就可以兼容Andriod 9.0以下的数据采集。

实际效果

上述为我们在MAUI + MASA Blazor 移动端项目中引入OpenTelemetry的实践,如果有更好的方式,欢迎与我们讨论沟通。