GitHub ActionsのSummaryで直接すぐ見れる形式でsbtのtaskのchartを出す方法

OSSにしてpublishしようかと思っていたけど、細かい部分をいい感じにするのが面倒なので一旦雑に貼り付けておく

(publishは後でやるかもしれない、未定)

package sbt // package privateなclassを参照したいので

import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets
import sbt.internal.AbstractTaskExecuteProgress
import sbt.internal.ShutdownHooks
import scala.collection.compat.*
import scala.concurrent.duration.*

/**
  * sbt標準のchrome trace形式ではなく、GitHubのmarkdownでそのまま表示できる形式で出力する用
  * [[https://github.com/sbt/sbt/blob/eec3c32cc841365799eaa5460272e8287b975f10/main/src/main/scala/sbt/internal/TaskTraceEvent.scala]]
  * [[https://github.com/sbt/sbt/pull/4576]]
  * [[https://mermaid.js.org/syntax/gantt.html]]
  */
class MermaidGanttChart extends AbstractTaskExecuteProgress with ExecuteProgress[Task] {
  import AbstractTaskExecuteProgress.Timer

  override def initial(): Unit = ()
  override def afterReady(task: Task[?]): Unit = ()
  override def afterCompleted[T](task: Task[T], result: Result[T]): Unit = ()
  override def afterAllCompleted(results: RMap[Task, Result]): Unit = ()
  override def stop(): Unit = ()

  ShutdownHooks.add(() => report())

  private[this] def report() = {
    if (anyTimings) {
      writeAll()
    }
  }

  def taskFilter(name: String, durationMicros: Long): Boolean = {
    // 一定以上時間がかかったものだけを記録
    durationMicros > 3.seconds.toMicros
  }

  /**
    * 時間がかかった順でtask名と秒数を出力
    */
  private def writeSlowTaskList(values: List[(Task[?], Timer)], write: String => Unit): Unit = {
    if (values.nonEmpty) {
      write("")
      write("## 時間がかかっているtaskの一覧")
      write("")
      values
        .takeRight(20)
        .reverseIterator
        .foreach { case (task, value) =>
          write(s"""1. ${simplifyTaskName(task)} """ + "%.1f".format(value.durationMicros / 1000000.0))
        }
      write("")
    }
  }

  /**
    * 表示上の見やすさのために、区別がつく範囲で名前を短くする
    */
  private def simplifyTaskName[A](t: Task[A]): String = {
    taskName(t)
      .replace(" ", "")
      .replace("/executeTests", "/test")
      .replace("compileIncremental", "compile")
  }

  private def writeAll(): Unit = {
    val out = new ByteArrayOutputStream()
    val values =
      currentTimings
        .filter(x => taskFilter(taskName(x._1), x._2.durationMicros))
        .toList
        .sortBy(_._2.durationMicros)
        .takeRight(100) // 多く出力しすぎても見づらいので、時間がかかった順で固定数にする

    val write: String => Unit = { s =>
      out.write(s.getBytes(StandardCharsets.UTF_8))
      out.write('\n')
    }
    writeTraceEvent(values, write)
    writeSlowTaskList(values, write)
    val result = out.toByteArray
    sys.env.get("GITHUB_STEP_SUMMARY").map(file).filter(_.isFile).foreach { summary =>
      // 存在している場合は、GitHub Actionsが用意してるファイルにも直接書きこむ
      IO.append(summary, result)
    }
    // CIのことだけを考えれば上記だけで十分であるが、ローカルの動作確認用に別のファイルにも書き込む
    IO.write(file("target") / "traces" / "trace.md", result)
  }

  private def writeTraceEvent(values: List[(Task[?], Timer)], write: String => Unit): Unit = {
    values.map(_._2.startMicros).minOption match {
      case Some(start) =>
        write("")
        write("```mermaid")
        write("gantt")
        write("  dateFormat x")
        write("  axisFormat %M:%S")
        val grouped = values.groupBy(_._2.threadId)
        val nowMillis = System.currentTimeMillis()

        def normalize(n: Long): Long = (n - start) + nowMillis

        def durationEvent(name: String, t: Timer): String = {
          val start = normalize(t.startMicros / 1000)
          val end = normalize((t.startMicros + t.durationMicros) / 1000)
          s"""  ${name} : ${start} , ${end}"""
        }

        grouped.foreach { case (threadId, values) =>
          write(s"  section ${threadId}")
          values.sortBy(_._2.startMicros).foreach { case (key, value) =>
            val name = simplifyTaskName(key)
            val n = name + " " + "%.1f".format(value.durationMicros / 1000000.0)
            write("  " + durationEvent(n, value))
          }
        }
        write("```\n\n")
      case None =>
        println("時間がかかったtaskが存在しませんでした")
    }
  }
}

project/適当な名前.scala に上記のファイルを置いて、あとはトップレベルの なんとか.sbt に以下を書いておけば、あとは何もしなくてもgithub actionsで実行するだけで全自動で出ます。

progressReports += Keys.TaskProgress(new MermaidGanttChart())

GitHubのこの仕様自体の詳細な解説はしないので、公式ドキュメント読んでください。

https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary

GITHUB_STEP_SUMMARY という特別に用意された環境変数を解決した文字列をファイル名として扱って、そこに追記していけば、shell scriptからではなくても直接Scalaなど任意の言語から書き込めます。

このchartの記法のドキュメントはこれ?

https://mermaid.js.org/syntax/gantt.html

そもそもGantt Chartをsbtのこのtaskの表示に使うのは適切なのか???というと、おそらく微妙ですが、他にいい感じのものがなかったので、これで無理やり代用しました。 threadごとに分けないと(重ねてしまうと)表示されない仕様らしいので、あえてthreadごとに分けています。

元々のchromeの方式で見た方が、拡大縮小や、その他クリックすると詳細情報が表示されて圧倒的に見やすいですが、これの方が何もしなくても気軽に見れて、ある程度継続的に自動で保存されるメリットがあります。

OSSだと基本的に無料なので、細かい効率気にせずに分割して富豪的にCIのリソース使って実行してることが大半なので、分割した状態で実行しても、あまりインパクトがないというか、嬉しさがわからないかもしれません。

今回のサンプルも、あえて普通とは違って全部まとめてcompileにしてみました。

jvmか?jsか?nativeか?にかかわらず、明らかにcore部分のcompileに時間がかかっていることがわかりますね。 これはcompileに限らず、testでもなんでも、任意のsbtのtaskで出力が可能です。

若干余談になりますが、mermaidの仕様的には以下のようなtextを吐き出しているだけなので、sbtのtask関係なく、割と気軽にchart吐き出して利用するのは便利なので、いろいろなものを出してみると面白いと思います。

gantt chart以外にも種類がいっぱいあるので。

gantt
  dateFormat x
  axisFormat %M:%S
  section 247
    effectJVM/Compile/compile 5.1 : 1716968012765 , 1716968017869
  section 224
    coreJS/Compile/compile 148.6 : 1716967869059 , 1716968017678
    iterateeNative/Compile/compile 7.5 : 1716968022677 , 1716968030148
    scalacheckBindingJS/Compile/compile 10.8 : 1716968032347 , 1716968043104
    testsJVM/Test/compile 59.4 : 1716968043325 , 1716968102712
  section 225
    coreJVM/Compile/compile 143.4 : 1716967867846 , 1716968011243
    effectNative/Compile/compile 6.3 : 1716968016365 , 1716968022633
    exampleJVM/Compile/compile 11.6 : 1716968023106 , 1716968034742
    testsJS/Test/compile 55.6 : 1716968043437 , 1716968098999
  section 245
    effectJS/Compile/compile 6.2 : 1716968018448 , 1716968024683
    scalacheckBindingNative/Compile/compile 11.0 : 1716968032100 , 1716968043146
  section 241
    coreNative/Compile/compile 147.4 : 1716967867810 , 1716968015199
    iterateeJS/Compile/compile 7.3 : 1716968024796 , 1716968032100
    exampleJS/Compile/compile 13.0 : 1716968034742 , 1716968047768
  section 250
    scalacheckBindingJVM/Compile/compile 9.1 : 1716968023101 , 1716968032234
    testsNative/Test/compile 56.0 : 1716968045070 , 1716968101088
  section 246
    iterateeJVM/Compile/compile 5.1 : 1716968017947 , 1716968023045
    exampleNative/Compile/compile 14.6 : 1716968030191 , 1716968044817