haneuma.log

生きるのをがんばりたい

仕事のがんばり具合を記録して可視化する(記録する編)

はじめに

これは Yahoo! JAPAN 18 新卒 Advent Calendar 2018 の21日目の記事です。

みなさん仕事がんばってますか?自分は新卒らしくがんばっています。がんばっているので、がんばり具合を記録して可視化します。

やりたいことはだいたいこういうことです。 pokutuna.hatenablog.com

ざっくり書くと:

  • 開いているウィンドウのログを記録する
  • グラフにする

ということをやります。どんなウィンドウを開いていたかを時系列に並べてグラフにすれば「10時から1時間くらい Emacs 開いてるからコード書いてるんだな、がんばってるっぽいぞ!」というのがわかります。これががんばり具合です。

セキュリティ的に、社外秘を含む情報を扱う端末のログの外部送信は問題があるので、このような取り組みを社内の環境で行えるような仕組みを自分で作ります。今回は記録するところについて書きます。

できたもの

社用 PC が Mac なので Cocoa でアプリケーションを作ります。Swift は書いたことがないのでインターネットを駆使してがんばります。がんばると、このような JSON が獲得できます。

[
  {
    "kCGWindowAlpha" : 1,
    "kCGWindowLayer" : 0,
    "kCGWindowMemoryUsage" : 1128,
    "kCGWindowSharingState" : 0,
    "kCGWindowOwnerPID" : 39418,
    "kCGWindowNumber" : 154478,
    "kCGWindowOwnerName" : "Xcode",
    "kCGWindowStoreType" : 1,
    "kCGWindowBounds" : {
      "X" : 135,
      "Height" : 877,
      "Y" : 23,
      "Width" : 1305
    },
    "kCGWindowName" : "AppDelegate.swift"
  },
  {
    "kCGWindowAlpha" : 1,
    "kCGWindowLayer" : 0,
    "kCGWindowMemoryUsage" : 1128,
    "kCGWindowSharingState" : 1,
    "kCGWindowOwnerPID" : 2160,
    "kCGWindowNumber" : 151228,
    "kCGWindowOwnerName" : "Google Chrome",
    "kCGWindowStoreType" : 1,
    "kCGWindowBounds" : {
      "X" : 170,
      "Height" : 62,
      "Y" : 92,
      "Width" : 366
    },
    "kCGWindowName" : ""
  }, ...

開いているウィンドウの名前と、どのアプリケーションから起動されているかがわかりますね。おおむね重なり順も合っているはずですが、雑に作ったので怪しいかもしれない。 JSON が得られたので、Elasticsearch + Kibana で可視化できそうな感じがしますね。

しくみ

常駐の Cocoa App で5秒ごとにウィンドウログ取得して、JSON 形式で保存させています。XCode の気持ちになって、しかるべき場所にこのようなコードが書いてあると思ってください。

func applicationDidFinishLaunching(_ aNotification: Notification) {
    Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(self.saveLog), userInfo: nil, repeats: true)
}

@objc func saveLog() -> Void {
    let filename = self.getDocumentsDirectory().appendingPathComponent("\(NSDate().timeIntervalSince1970).json")

    let windowList = self.getWindowList()
    let data: Data = try! JSONSerialization.data(withJSONObject: windowList!, options: [])

    do {
        let windowInfoList = try JSONDecoder().decode([MyWindowInfo].self, from: data)

        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted
        let encoded = try! encoder.encode(windowInfoList)

        do {
            try String(data: encoded, encoding: .utf8)!.write(to: filename, atomically: true, encoding: String.Encoding.utf8)
        } catch {
            print(error)
        }
    } catch {
        print(error)
    }
}

getWindowListMyWindowInfo あたりが大事なので、詳しく説明します。

ウィンドウリストを取得する

CGWindowListCopyWindowInfo() でさっくりウィンドウリストが取得できます。CFArray で結果が返ってくるので、外側は NSArray にキャストします。中身は NSDictioanry とします。.optionAll ですべてのウィンドウを取ってくると常駐アプリケーションや Desktop プロセスなどのがんばり具合に寄与しないものが取れてしまうので、適当にフィルタします。高さが23以下のウィンドウは常駐アプリケーションで、layer が 0 より小さいのは Desktop プロセスとかその辺です。用途によっては自作のフィルタではなく、CGWindowListCopyWindowInfo() のオプションでいい感じに必要なウィンドウだけ取得することもできます。

func getWindowList() -> [NSDictionary]? {
        guard let windowList: NSArray = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) else {
            return nil
        }

        let swiftWindowList = windowList as! [NSDictionary]

        let filteredWindowList = swiftWindowList.filter { (windowIndo: NSDictionary) -> Bool in
            var flag: Bool = true

            // Bounds filter
            let bounds = windowIndo[kCGWindowBounds] as! NSDictionary
            let height = bounds["Height"] as! Int
            if height <= 23 {
                flag = false
            }

            // Layer filter
            let layer = windowIndo[kCGWindowLayer] as! Int
            if layer < 0 {
                flag = false
            }

            return flag
        }

        return filteredWindowList
    }

JSON に変換する

getWindowList() で得られるのは NSDictionary の NSArray なので、JSON 文字列に変換していきます。 JSONSerialization -> JSONDecode -> JSONEncode という手順でやります。 Serialization で潰したデータを一旦 Decode して、人間に優しい表記で Encode する感じです。

平たく書くとこうなっています。例外の気持ちになって、しかるべきコードに読み替えてください。

let windowList = self.getWindowList()  // -> [NSDictionary]

// windowList を1行に潰す
let data: Data = JSONSerialization.data(withJSONObject: windowList, options: [])

// ねじ込む
let windowInfoList = JSONDecoder().decode([MyWindowInfo].self, from: data)

// 人間に優しい表記にする
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let encoded = encoder.encode(windowInfoList)
let dst = String(data: encoded, encoding: .utf8)!

Decode するときは Codable を使います。潰したデータを任意の構造体にねじ込めます。便利ですね。

struct MyWindowInfo: Codable {
    var kCGWindowAlpha: Double
    var kCGWindowBounds: MyWindowBounds
    var kCGWindowIsOnscreen: Bool?
    var kCGWindowLayer: Int
    var kCGWindowMemoryUsage: Int
    var kCGWindowName: String?
    var kCGWindowNumber: Int
    var kCGWindowOwnerName: String
    var kCGWindowOwnerPID: Int
    var kCGWindowSharingState: Int
    var kCGWindowStoreType: Int

    struct MyWindowBounds: Codable {
        var Height: Int
        var Width: Int
        var X: Int
        var Y: Int
    }
}

ログについて

ログは溜めて使ってこそ価値がありますね。弊社もそのようにやっています。そういうわけで、運用にあたって調べておきたいことを調べておきます。

容量・転送

常駐アプリケーションで5秒毎にテキストデータを生成しているので、ログが気になります。見てみると1ファイルあたり 44KB でした。 1時間で 60 * 60 / 5 * 44 = 31680KB になります。32MB くらいですね。今月の必要労働時間は147時間15分らしいので、1ヶ月で (147 * 60 * 60 + 15 * 60) / 5 * 44 = 4664880KB になります。4.7GB くらいですね。 保存期間を1年間とすると 56GB くらいになります。現実的な容量ですね。

また、ログの保存と可視化のために、適当なサーバに定期的に転送する必要があります。 1時間ごとに転送するとして 32MB くらいなので、現代のインターネットなら問題なさそうです。 社内のネットワークを計測したところ、7.80MB/sec でした。5,6秒あれば転送できそうです。

メモリ使用量

だいたい 12MB くらい食べています。社用 PC のメモリは 16GBなので、常駐させても問題なさそうです。

セキュリティ

ウィンドウ名に社外秘情報が含まれる可能性があるので、生ログは社内にしまっておく必要があります。

まとめ

いつ、どのくらい、どんなウィンドウ(アプリケーション)を開いていたかのログを使って、仕事のがんばり具合を記録・可視化する取り組みについて書きました。 この記事では Cocoa でロガーを作るところと、ログの保管について少しだけ検証しました。可視化するところは作業中なので、また別の記事に書きます。

Swift は初めて書いたので、記事内のソースコードについてコメントがあればぜひお願いします。また、このプログラムは社内の GHE に置いているので、興味のある方は入社して一緒にがんばり具合を記録しましょう。

以上、Yahoo! JAPAN 18 新卒 Advent Calendar 2018 の21日目の記事でした。

参考