专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

MASA MAUI APP前端监控指南

ins518 2024-09-18 18:05:27 技术文章 19 ℃ 0 评论

近期由于我们APP项目(MAUI+MASA Blazor),需要做运营数据采集埋点,经过综合考虑后,决定采用接入OpenTelemetry SDK的方式,由于目前OpenTelemetry(简称:otel)的可测性大部分都是基于后端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 != )
{
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 != )
{
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 = ;
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 != && 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.IsOrEmpty(_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的实践,如果有更好的方式,欢迎与我们讨论沟通。值得一提的是,前端的监控可以跟后端打通,统一接入到MASA Stack中实现统一监控,效果更佳!




如果你对我们的开源项目感兴趣,无论是代码贡献、使用、提 Issue,欢迎联系我们


Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表