MASA MAUI APP前端监控指南
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
接入过程
- 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" />
- 注入
OpenTelemetry SDK
:
由于当前的OpenTelemetry SDK
接入都是针对后端api接口,而MAUI和Blazor前端没有对应的实现,所以需要我们首先需要自定义一个追踪的ActivitySource,然后在相应需要追踪的上下文中使用ActivitySource
对Activity
进行管理。
//注入全局的 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);
- 进行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
- 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
的管理:
OnInitialized
进行 当前页面或组件的Activity
对象的创建和初始化;OnInitializedAsync
在页面首次加载完成后,从ILocalStorage
获取当前已经登录的用户Id;OnAfterRender
页面首次渲染完成后,记录页面的首次显示时间;Dispose
销毁该组件对象时,销毁Activity
对象,并自动执行数据上报。
Blazor使用
Blazor页面和组件使用时,必须继承MyCompontentBase
,相应的生命周期方法内,必须调用base.
对应的生命周期方法;如果有特别的需求,需要向Activity
中写入额外的Tag,直接调用Activity.Currrent?.SetTag("tag1","tag1value")
即可,如果要打印日志,直接调用Logger.LogInformation("日志内容")
,相应的日志和Activity
就会被OpenTelemetry SDK
自动管理起来
问题
- OTEL 默认上报采用
Grpc
协议,如果部署的OTEL为内网,采用的IP地址加端口,在Andriod 9.0及以上是可以使用的,8.0及以下还需要验证;如果使用了域名和https的方式,则只能在Andriod 10.0及以上版本使用; - 如果使用的是
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的实践,如果有更好的方式,欢迎与我们讨论沟通。