客户端日志方案

背景说明

  对于发布出去的app大部分时候确实都能保证业务正常运行的,但由于版本的迭代,客户端和服务端业务逻辑一直在更新变化,而且运营数据的配置也会相应更改,这些变化就可能使得客户端不能按预定逻辑运行或出现异常。如果一旦有检测(用户反馈或服务端预警)到有非正常的运行现象,却没有对应用户的日志来分析,而仅通过现象来排查代码将使得问题解决变得困难。
  日志作为程序运行状态和路径的记录,是跟踪和重现问题的重要依据,特别是对于线上问题的定位显得尤为重要。 因此,规范的日志打印和合理的日志获取流程有利于问题修复的效率提升。

实现目的

  • 增强客户端app在发布后对可能问题的可追溯性;
  • 方便在开发过程中对提测后bug的重现;
  • 减少打印日志后对程序性能的影响;
  • 规范日志打印,增强可读性;

日志分类

  • 业务日志:记录程序运行状态路径,是日志的主体信息。一般是在代码的关键点(比如在遇到两个分支策略使得程序运行结果有较大出入时)来打印业务日志,为后续排查问题提供程序执行路径依据。
  • 异常日志:不仅包括标识程序crashexception信息日志(携带异常堆栈),而且还要包括可预见性的程序运行跟预期不符合的情况。

日志规范

  1. 日志的TAG定义
    在类的首行使用final static String类型定义日志TAG,名称可以是类名或其他有意义能唯一标示的名称。如:
//错误(混淆会修改类名)
private final static String TAG = TestLog.class.getSimpleName(); 

private final static String TAG = "aa"; //错误

private final static String TAG = "TestLog"; //正确
  1. 应严格按照日志等级打印,以便提高性能以及后续日志抓取、分析的过滤。
    日志级别从低到高定义:Log.v() < Log.d() < Log.i() < Log.w() < Log.e()
  • A、程序调试阶段的调试日志(最好合并代码到 dev 之前清理)以及不需要在正式发版后打印到文件(终端)的使用Log.v()Log.d()打印,便于后续视情况终端打印还是写入日志文件或者直接屏蔽。
  • B、关键业务执行路径日志、重要日志信息使用Log.i()打印,此时对于远程抓取的情况需要存储文件。
  • C、有异常判断的条件,但此时跟预期运行有差异的情况使用Log.w() (比如对空指针的有前提判断,但预期不为空的情况)。
  • D、catch异常使用Log.e()打印。
  1. 所有日志打印开关需要前置。如:
if (Log.LOGED) Log.d(TAG, "message");  //优化字符串拼接损耗

  这里的开关前置,很多人不能理解,其实就是因为调用Log.d()函数的传参message很有可能是通过复杂运算拼接成的(比如,打印http接口的返回结果等),在动态的关闭/打开日志开关后,能较大程度的提升性能。[参考android的源码中,发现很多日志打印也是采用了这种开关前置的形式]。

  1. 异常打印信息使用Log.e()打印,且需要带上Throwable异常堆栈信息。如:
  try {
     //do something...
  } catch (Exception e) {
      Log.e(TAG, e.getMessage());     //错误
      Log.e(TAG, "method name()" + e.getMessage()); //错误
      Log.e(TAG, "method name()", e); //正确
  }
  1. 代码中禁止使用System.out.println("message")来打印日志。
  2. 对于容易产生不可预知异常处需打印入口和出口日志,如: http 请求网络日志、推送等。
  3. 需要直接打印的bean对象或复杂数据结构时,需打印其toString(),并实现该方法,参数使用StringBuilder拼凑。如:
  public class TestBean {
    private String key;
    private int value;
    private boolean success;

    @Override
    public String toString() {
      StringBuilder builder = new StringBuilder();
      builder.append("{key:")
      .append(key)
      .append(", value:")
      .append(value)
      .append(", sucess:")
      .append(success)
      .append("}");
    return builder.toString();
    }
  }

  TestBean bean = new TestBean();
  bean.key = "test-key";
  bean.value = 100;
  bean.success = true;
  if (Log.LOGED) Log.d(TAG, "test: " + bean.toString());
  1. for循环中尽量不打印日志,确实有必要打印时,可以先在循环里使用 StringBuilder拼凑成可读性较好的字符串,然后在循环结束后一次性打印写入文件。
  2. 打印的日志信息不仅仅是简单的输出变量值,诸如“变量名=value”等格式的信息,应携带其他有可读性的语句或提炼通用词句来拼凑日志信息,以使最终的日志文件具有可读性。比如:
  Log.i(TAG, "request >> url: " + task.getUrl() + ", code: " + task.hashCode() 
  + ", type: " + task.getRequestType() + ", expire: " + sAcExpired);

  Log.i(TAG, "response << http: " + task.getHttpName() + ", code: " + task.hashCode() 
  + ", ac:" + ac + ", result:" + formatJson(content));
  1. 尽量不写入重复或多余日志。

日志影响

日志打印存储文件时,需要尽可能少的影响程序本身的性能,因为性能影响了程序的流畅性,而流畅性直接影响了用户体验。
最基本的流畅性保证是使用了日志方案后不会导致app的卡顿,但是流畅性不仅包括了系统没有卡顿,还要尽量保证没有CPU峰值等。

方案一:简易本地版

  如果不需要考虑日志的存储、上传等与服务端连通的操作而只是在终端输出时,问题也将变得简单,我们直接使用android.util.Log进行日志打印即可。剩下的问题就只剩下如何控制日志开关、如何打印其他辅助信息(如线程、堆栈等)了。
  经常看到很多app对这种本地日志开关都会使用BuildConfig.DEBUG这个gradle帮我们生成的变量来作为日志开关的标志(debug版本打开日志,release版本关闭日志),在实际开发过程中会发现以这个变量为日志的开关标志其实有一定局限性。比如,某天xx领导拿着自己的手机给我们现场反馈刚上线的版本出现了某个偶现严重问题,当你想看下日志分析的时候,发现release版本日志被关掉了,看不了!!!尴尬啊。。。
  于是琢磨着有没有什么其他手段能动态修改这个日志开关呢?想到前几年开发中经常用到SystemProperties来存储一些全局配置信息,刚好能配合这个日志开关的使用,不管什么版本的app,需要看日志的时候,我们通过命令行修改这个配置就可以打开这个开关了,如输入:adb shell setprop ro.tech.log true

先简单介绍一下属性系统:
  属性系统是android的一个重要特性,它作为一个服务运行,管理系统配置和状态;所有这些配置和状态都是属性;每个属性是一个键值对(key/value pair),其类型都是字符串。这些属性可能是有些资源的使用状态、进程的执行状态、系统的特有属性等。
系统给的注释:Gives access to the system properties store. The system properties store contains a list of string key-value pairs.
  对开发者来说更重要的是SystemProperties@hide起来了,意思是普通应用开发不能使用属性系统。经过测试验证(在root手机和非root手机上验证过,不排除某些rom有限制),反射这个类的set()/get()接口仍是有效的,只是谷歌没有开放给开发者使用而已。有了这样的前提,我们就可以使用命令行设置属性打开(关闭)日志开关,app代码反射读取属性来获取开关来实现日志的动态开关了。

另外,SystemPropertieskey的命名有一些规则限制,比较常用的是以ro开头和persist开头的属性:

  • 如果属性名称以ro.开头,那么这个属性被视为只读属性。一旦设置,属性值不能改变,需要重启还原。
  • 如果属性名称以persist.开头,当设置这个属性时,其值将写入/data/property,且可以重复写入。

首先,定义反射属性系统的接口函数(参见文章《反射相关知识及jOOR反射库介绍》):

    private static boolean getBoolean(String propName, boolean def) {
        return Reflect.on("android.os.SystemProperties").call("getBoolean", propName, def).get();
    }

根据属性系统key的命名规则定义日志开关key

    /**
     * 日志开关设置
     *
     * adb shell setprop ro.tech.log true
     */
    private static final String LOG_ENABLE_PROP = "ro.tech.log";

定义日志开关

DLog {
    //...
    //LOGED为静态的final变量,程序启动时读取开关
    public final static boolean LOGED = BuildConfig.DEBUG || getBoolean(LOG_ENABLE_PROP, false);
    //...
}

打印日志,读取DLog.LOGED属性(在启动app前,通过命令行输入修改属性指令:adb shell setprop ro.tech.log true

if (DLog.LOGED) DLog.i(TAG, "MainActivity is oncreated!!");

其他辅助信息,如是否使用默认tag打印、是否打印线程信息等设置均可采用该方式实现,具体参见GHDemo-DLog.java的实现。

方案二:服务端预警版

使用场景

  • 服务端检测http业务接口调用异常(调用量偏高或偏低、接口参数有误等),获取客户端网络日志分析http调用逻辑的可能问题。(典型用例:消息提醒需求的红点接口调用量大问题)。
  • 线上用户反馈但难以复现的严重问题,通过获取用户反馈问题的描述及用户日志分析解决此类问题。(典型用例:排查用户反馈偷跑流量问题)
  • app灰度期间借助第三方的崩溃统计平台(友盟、bugly等)统计崩溃情况,客户端的业务日志能方便重现崩溃的操作路径有利于找到crash的本质原因。

方案描述

  服务端借助push系统主动发出日志开关或日志拉取的透传指令,客户端app收到推送后,解析该日志指令并执行相应处理(包括操作日志开关、压缩并上传已有日志文件),在服务端收到日志文件后提供日志文件存储下载服务,开发人员下载对应用户日志分析问题。
  该方案完全由服务端的指令触发自动完成,整个流程不需要用户实际参与,相比要求用户配合开发人员获取日志的方式大大缩短了解决问题的时间和整体流程。

方案实现

该方案实现有服务端和客户端的工作量,因此需要两端协助完成,具体时序流程如下图所示:

日志方案时序图

时序图中包含了手动拉取自动批量拉取两种场景:

  • 手动拉取:用户或预警反馈问题并携带客户端imei,开发人员通过后台日志管理平台以imei为唯一标识触发单条日志指令。
  • 自动批量拉取:大数据生成报表时,获取存在可能异常用户样本列表,通过业务接口自动获取样本列表日志。

具体细节功能

服务端

  • 定义日志指令参数格式。
  • 对接push系统,实现查询在线状态、日志指令(包括开关控制、日志拉取、指令有效期控制)推送接口,且支持批量推送。
  • 实现日志指令操作的后台操作界面。
  • 实现日志文件存储、生成日志下载链接。

客户端

  • 实现日志打印文件和终端输出,包括日志压缩、文件大小、有效期及存储空间控制(可接入第三方日志库实现,如:xloglog4j 等)
  • 实现push推送的透传日志指令(包括日志开关、日志上传等)解析和相应执行操作。
  • 实现日志文件http上传。
  • http请求接口增加基本参数(如:版本号、imei、安卓版本、系统版本等),便于后续出现问题是的过滤分析。

参考文档
xlog: http://dev.qq.com/topic/581c2c46bef1702a2db3ae53

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342